
今天要來用 Template Driven Forms 的方式實作一個很簡易的動態表單,使用上有點像是保險業者的系統,可以新增多名被保人,也可以編輯與刪除被保人。
具體的規格需求如下:
- 被保險人的欄位:
- 姓名(文字輸入框)
- 最少需要填寫兩個字,如驗證有誤則顯示錯誤訊息姓名至少需兩個字以上
- 最多只能填寫十個字
 
- 性別(單選)
- 年齡(下拉選單)
- 選項: 18 歲、 20 歲、 70 歲、 75 歲
 
 
- 以上欄位皆為必填,如驗證有誤則顯示錯誤訊息此欄位為必填
- 以上驗證皆需在使用者輸入時動態檢查
- 按下新增被保險人按鈕可以新增被保險人
- 按下刪除被保險人按鈕可以刪除被保險人
- 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
- 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態
規格需求看清楚之後,我們就來開始實作吧!
開始實作
首先我們先準備好基本的 HTML :
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 
 | <form><fieldset>
 <legend>被保人</legend>
 <p>
 <label for="name">姓名:</label>
 <input
 type="text"
 name="name"
 id="name"
 required
 maxlength="10"
 minlength="2"
 />
 <span class="error-message"></span>
 </p>
 <p>
 性別:
 <input type="radio" name="gender" id="male" value="male">
 <label for="male">男</label>
 <input type="radio" name="gender" id="female" value="female">
 <label for="female">女</label>
 </p>
 <p>
 <label for="age">年齡:</label>
 <select name="age" id="age" required>
 <option value="">請選擇</option>
 <option value="18">18歲</option>
 <option value="20">20歲</option>
 <option value="70">70歲</option>
 <option value="75">75歲</option>
 </select>
 <span class="error-message"></span>
 </p>
 <p><button type="button">刪除</button></p>
 </fieldset>
 <p>
 <button type="button">新增被保險人</button>
 <button type="submit">送出</button>
 </p>
 </form>
 
 | 
未經美化的畫面應該會長這樣:

基本的 HTML 準備好之後,我建議對於 Angular 還沒那麼熟悉的朋友先不要一口氣就想要直接把它做成動態的,先把它當成靜態表單來做會比較簡單一些。
因此,我們先準備相關的屬性與方法:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 
 | import { Component } from '@angular/core';import { ValidationErrors } from '@angular/forms';
 
 @Component({
 selector: 'app-template-driven-forms-async-insured',
 templateUrl: './template-driven-forms-async-insured.component.html',
 styleUrls: ['./template-driven-forms-async-insured.component.scss']
 })
 export class TemplateDrivenFormsAsyncInsuredComponent {
 
 
 name = '';
 
 
 gender = '';
 
 
 age = '';
 
 
 nameErrorMessage = '';
 
 
 ageErrorMessage = '';
 
 
 
 
 
 
 
 
 insuredNameChange(name: string, errors: ValidationErrors | null): void {
 this.name = name;
 this.nameErrorMessage = this.getErrorMessage(errors);
 }
 
 
 
 
 
 
 
 
 insuredAgeChange(age: string, errors: ValidationErrors | null): void {
 this.age = age;
 this.ageErrorMessage = this.getErrorMessage(errors);
 }
 
 
 
 
 
 
 submit(): void {
 
 }
 
 
 
 
 
 
 
 
 
 private getErrorMessage(errors: ValidationErrors | null): string {
 let errorMessage = '';
 if (errors?.required) {
 errorMessage = '此欄位必填';
 } else if (errors?.minlength) {
 errorMessage = '姓名至少需兩個字以上';
 }
 return errorMessage;
 }
 }
 
 | 
準備好相關的屬性和方法之後,我們直接把他們跟 Template 綁定:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 
 | <form (ngSubmit)="submit()"><fieldset>
 <legend>被保人</legend>
 <p>
 <label for="name">姓名:</label>
 <input
 type="text"
 name="name"
 id="name"
 required
 maxlength="10"
 minlength="2"
 #nameNgModel="ngModel"
 [ngModel]="name"
 (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors)"
 />
 <span class="error-message">{{ nameErrorMessage }}</span>
 </p>
 <p>
 性別:
 <input
 type="radio"
 name="gender"
 id="male"
 value="male"
 required
 [(ngModel)]="gender"
 >
 <label for="male">男</label>
 <input
 type="radio"
 name="gender"
 id="female"
 value="female"
 required
 [(ngModel)]="gender"
 >
 <label for="female">女</label>
 </p>
 <p>
 <label for="age">年齡:</label>
 <select
 name="age"
 id="age"
 required
 #ageNgModel="ngModel"
 [ngModel]="age"
 (ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors)"
 >
 <option value="">請選擇</option>
 <option value="18">18歲</option>
 <option value="20">20歲</option>
 <option value="70">70歲</option>
 <option value="75">75歲</option>
 </select>
 <span class="error-message">{{ ageErrorMessage }}</span>
 </p>
 <p><button type="button">刪除</button></p>
 </fieldset>
 <p>
 <button type="button">新增被保險人</button>
 <button type="submit">送出</button>
 </p>
 </form>
 
 | 
從目前的程式碼應該不難發現,大體上跟我們第二天的實作內容差不多、結構也差不多,應該沒有什麼難度。
如果大家在這邊有遇到問題,大致上可以檢查看看自己有沒有引入 FormsModule ,抑或者是表單欄位上是否有 name 屬性,我就不再贅述囉。
目前的結果:

有了基本的互動效果之後,我們就可以開始來思考怎麼樣把這個表單變成動態的。
相信大家一定知道,既然我們要讓被保人可以被新增或刪除,表示我們應該是會用陣列來存放這些被保人的資料,所以我們可以先將這些我們需要的資料欄位定義一個型別以便後續使用。
像是這樣:
| 12
 3
 4
 5
 6
 7
 
 | export type Insured = {name: string;
 gender: string;
 age: number;
 nameErrorMessage: string;
 ageErrorMessage: string;
 };
 
 | 
或者是這樣:
| 12
 3
 4
 5
 6
 7
 
 | export interface Insured {name: string;
 gender: string;
 age: number;
 nameErrorMessage: string;
 ageErrorMessage: string;
 };
 
 | 
甚至是這樣:
| 12
 3
 4
 5
 6
 7
 
 | export class Insured {name: string;
 gender: string;
 age: string;
 nameErrorMessage: string;
 ageErrorMessage: string;
 };
 
 | 
這三種定義型別的方式基本上都可以,我就不多解釋他們之間的差異了,我個人近期是滿喜歡用第一種的。
接著我們就可以將原本那些單個的屬性拿掉,改成用陣列的方式,像是這樣:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 
 | import { Insured } from './insured.type';
 
 @Component({
 
 })
 export class TemplateDrivenFormsAsyncInsuredComponent {
 
 
 insuredList: Insured[] = [];
 
 
 
 
 
 
 
 
 
 }
 
 | 
這些單個的屬性移除掉之後,原本有使用到它們的部分就會壞掉,所以我們要將它們改為使用傳進來的被保人的資料,像這樣:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 
 | import { Insured } from './insured.type';
 
 @Component({
 
 })
 export class TemplateDrivenFormsAsyncInsuredComponent {
 
 
 insuredList: Insured[] = [];
 
 
 
 
 
 
 
 
 
 insuredNameChange(name: string, errors: ValidationErrors | null, insured: Insured): void {
 insured.name = name;
 insured.nameErrorMessage = this.getErrorMessage(errors);
 }
 
 
 
 
 
 
 
 
 
 insuredAgeChange(age: string, errors: ValidationErrors | null, insured: Insured): void {
 insured.age = age;
 insured.ageErrorMessage = this.getErrorMessage(errors);
 }
 
 
 }
 
 | 
接著我們就可以到 Template 裡,將所有被保人的資料用 *ngFor 的方式迴圈出來,並將原本用單個屬性綁定的部份也改為綁定迴圈出來的被保人資料:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 
 | <form (ngSubmit)="submit()">
 <fieldset *ngFor="let insured of insuredList">
 <legend>被保人</legend>
 <p>
 <label for="name">姓名:</label>
 
 <input
 type="text"
 name="name"
 id="name"
 required
 maxlength="10"
 minlength="2"
 #nameNgModel="ngModel"
 [ngModel]="insured.name"
 (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
 />
 <span class="error-message">{{ insured.nameErrorMessage }}</span>
 </p>
 <p>
 性別:
 
 <input
 type="radio"
 name="gender"
 id="male"
 value="male"
 required
 [(ngModel)]="insured.gender"
 >
 <label for="male">男</label>
 <input
 type="radio"
 name="gender"
 id="female"
 value="female"
 required
 [(ngModel)]="insured.gender"
 >
 <label for="female">女</label>
 </p>
 <p>
 <label for="age">年齡:</label>
 
 <select
 name="age"
 id="age"
 required
 #ageNgModel="ngModel"
 [ngModel]="insured.age"
 (ngModelChange)="insuredAgeChange(ageNgModel.value, ageNgModel.errors, insured)"
 >
 <option value="">請選擇</option>
 <option value="18">18歲</option>
 <option value="20">20歲</option>
 <option value="70">70歲</option>
 <option value="75">75歲</option>
 </select>
 <span class="error-message">{{ insured.ageErrorMessage }}</span>
 </p>
 <p><button type="button">刪除</button></p>
 </fieldset>
 <p>
 <button type="button">新增被保險人</button>
 <button type="submit">送出</button>
 </p>
 </form>
 
 | 
接著我們就可以儲存以查看目前的結果:

咦?!怎麼表單欄位不見了?!
別緊張,這是因為 insuredList 現在是個空陣列呀!
接下來我們再加個新增被保險人與刪除被保險人的函式:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 
 | 
 
 
 
 addInsured(): void {
 const insured: Insured = {
 name: '',
 gender: '',
 age: '',
 nameErrorMessage: '',
 ageErrorMessage: ''
 };
 this.insuredList.push(insured);
 }
 
 
 
 
 
 
 
 deleteInsured(index: number): void {
 this.insuredList.splice(index, 1);
 }
 
 | 
然後把它們綁定到按鈕上,並且在 *ngFor 裡新增索引的宣告,以供刪除時使用 :
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 
 | <form (ngSubmit)="submit()"><fieldset *ngFor="let insured of insuredList; let index = index">
 
 <p><button type="button" (click)="deleteInsured(index)">刪除</button></p>
 </fieldset>
 <p>
 <button type="button" (click)="addInsured()">新增被保險人</button>
 <button type="submit">送出</button>
 </p>
 </form>
 
 | 
結果:

雖然我們的表單就差不多快完成了,但其實我們的表單目前有兩個問題,不曉得大家有沒有發現?
問題一

對專業的前端工程師來說,我們做出來的表單一定要讓人家有良好的使用者體驗。
為此,我們通常會使用一些 HTML 的屬性來讓我們的表單更為人性化,像是在 label 上加 for 。
但問題來了, for 要跟 id 搭配使用,但 id 一整頁只會有一個,而我們可能會有 N 個被保險人,怎辦?
這時候我們可以善用陣列的索引值來幫我們達成這個目的,像是這樣:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 
 | <label [for]="'name-' + index">姓名:</label><input
 type="text"
 [name]="'name-' + index"
 [id]="'name-' + index"
 required
 maxlength="10"
 minlength="2"
 #nameNgModel="ngModel"
 [ngModel]="insured.name"
 (ngModelChange)="insuredNameChange(nameNgModel.value, nameNgModel.errors, insured)"
 />
 
 | 
我知道很醜,但沒辦法,這是天生的侷限。
對了, name 屬性也要噢!因為表單裡的 name 也是唯一性的。
問題二

這個問題是因為在畫面重新渲染完之後, NgForm 裡面 Key 值為 xxx-0 的 NgModel 們就不見了,只留下 xxx-1 的 NgModel 們。在這之後如果再按新增被保人時,由於新增的那一筆的索引是 1 ,就又會把原本留下的 Key 值為 xxx-1 的 NgModel 們蓋掉,導致大家現在所看到的情況。

解決方式其實說難不難,因為其實 *ngFor 有個 trackBy 的參數,只要傳入這個參數就可以解決這個問題。但說簡單也不簡單,不知道原因跟解法的人就會卡上一段時間。
其實我一開始也卡住,還跟社群的人求救,進而引出一大串的討論(笑)。
方式是先在 .ts 裡加一個函式:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 
 | 
 
 
 
 
 
 trackByIndex(index: number): number {
 return index;
 }
 
 | 
然後在 *ngFor 的後面加上:
| 1
 | <fieldset *ngFor="let insured of insuredList; let index = index; trackBy: trackByIndex">
 | 
這樣就可以解決我們的問題了!
最後,我們就剩以下兩項事情還沒做:
- 任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
- 沒有被保險人時,送出按鈕皆呈現不可被點選之狀態
這兩件事情基本上可以看成同一件事情 ─ 判斷表單是否無效。
怎麼判斷呢?
大家記不記得上次有用到一個類別叫做 NgForm ,當表單內的驗證有誤時, NgForm 的屬性 invalid 就會為 true 。
所以我們一樣可以利用它來幫我們判斷,像這樣:
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | <form #form="ngForm" (ngSubmit)="submit()"><fieldset *ngFor="let insured of insuredList; let index = index">
 
 </fieldset>
 <p>
 <button type="button" (click)="addInsured()">新增被保險人</button>
 <button type="submit" [disabled]="insuredList.length === 0 || form.invalid">送出</button>
 </p>
 </form>
 
 | 
結果:

本日小結
今天的學習重點主要是在練習如何讓靜態的表單變成動態,雖然沒有多複雜,但可能也是會難倒大部分的初學者。
其實大體上的邏輯跟實作登入時是差不多的,大家之所以會卡住主要可能會是因為不知道如何讓靜態表單變成動態,而以 Template Driven Forms 的方式來說,滿多程式碼都會綁在 Template 上,大家在實作時要看清楚才不會出錯。
至於程式碼的部份我一樣會放在 Github - Branch: day10 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!