今天要來用 Reactive Forms 的方式再來實作一次昨天的表單。
具體的規格需求跟昨天差不多,如下所示:
被保險人的欄位:
姓名(文字輸入框)
最少需要填寫兩個字,如驗證有誤則顯示錯誤訊息姓名至少需兩個字以上
最多只能填寫十個字,如驗證有誤則顯示錯誤訊息姓名最多只能十個字
性別(單選)
年齡(下拉選單)
選項: 18 歲、 20 歲、 70 歲、 75 歲
以上欄位皆為必填,如驗證有誤則顯示錯誤訊息此欄位為必填
以上驗證皆需在使用者輸入時動態檢查
按下新增被保險人按鈕可以新增被保險人
按下刪除被保險人按鈕可以刪除被保險人
任一驗證有誤時,送出按鈕皆呈現不可被點選之狀態
沒有被保險人時,送出按鈕皆呈現不可被點選之狀態
規格需求看清楚之後,我們就來開始實作吧!
實作開始 首先我們一樣先準備好基本的 HTML :
1 2 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 <form *ngIf ="formGroup" [formGroup ]="formGroup" (ngSubmit )="submit()" > <fieldset > <legend > 被保人</legend > <p > <label for ="name" > 姓名:</label > <input type ="text" id ="name" formControlName ="name" /> <span class ="error-message" > {{ getErrorMessage("name") }}</span > </p > <p > 性別: <input type ="radio" id ="male" value ="male" formControlName ="gender" /> <label for ="male" > 男</label > <input type ="radio" id ="female" value ="female" formControlName ="gender" /> <label for ="female" > 女</label > </p > <p > <label for ="age" > 年齡:</label > <select id ="age" formControlName ="age" > <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" > {{ getErrorMessage("age") }}</span > </p > <p > <button type ="button" > 刪除</button > </p > </fieldset > <p > <button type ="button" > 新增被保險人</button > <button type ="submit" > 送出</button > </p > </form >
未經美化的畫面跟昨天長得一樣:
接著跟昨天一樣先把它當成靜態表單 來準備相關的屬性與方法:
1 2 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 import { Component, OnInit } from '@angular/core' ;import { FormBuilder, FormGroup, Validators } 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 { formGroup: FormGroup | undefined ; constructor (private formBuilder: FormBuilder ) {} ngOnInit(): void { this .formGroup = this .formBuilder.group({ name: [ '' , [Validators.required, Validators.minLength(2 ), Validators.maxLength(10 )] ], gender: ['' , Validators.required], age: ['' , Validators.required] }); } getErrorMessage(key: string ): string { const formControl = this .formGroup?.get(key); let errorMessage: string ; if (!formControl || !formControl.errors || formControl.pristine) { errorMessage = '' ; } else if (formControl.errors.required) { errorMessage = '此欄位必填' ; } else if (formControl.errors.minlength) { errorMessage = '姓名至少需兩個字以上' ; } else if (formControl.errors.maxlength) { errorMessage = '姓名至多只能輸入十個字' ; } return errorMessage!; } submit(): void { } }
準備好相關的屬性和方法之後,我們直接把他們跟 Template 綁定:
1 2 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 <form *ngIf ="formGroup" [formGroup ]="formGroup" (ngSubmit )="submit()" > <fieldset > <legend > 被保人</legend > <p > <label for ="name" > 姓名:</label > <input type ="text" id ="name" formControlName ="name" /> <span class ="error-message" > {{ getErrorMessage('name') }}</span > </p > <p > 性別: <input type ="radio" id ="male" value ="male" formControlName ="gender" > <label for ="male" > 男</label > <input type ="radio" id ="female" value ="female" formControlName ="gender" > <label for ="female" > 女</label > </p > <p > <label for ="age" > 年齡:</label > <select id ="age" formControlName ="age" > <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" > {{ getErrorMessage('age') }}</span > </p > <p > <button type ="button" > 刪除</button > </p > </fieldset > <p > <button type ="button" > 新增被保險人</button > <button type ="submit" > 送出</button > </p > </form >
目前為止,大體上跟我們上次的實作差不多,應該沒有什麼難度。
不過這次綁定 FormControl
的方式,我改成用 formControlName="name"
,而不是上次的 [formControl]="nameControl"
,大家可以自行選用喜歡的方式。
如果大家在這邊有遇到問題,可以檢查看看自己有沒有引入 FormsModule
與 ReactiveFormsModule
,我就不再贅述囉。
目前的結果:
有了基本的互動效果之後,我們就可以開始來思考怎麼樣把這個表單變成動態的。
跟昨天一樣的是,既然我們要讓被保人可以被新增或刪除,表示我們應該是會用陣列來表達這些被保人的資料,也就是說,我們現在的 FormGroup
要從 1 個變成 N 個。
之前曾經提到,我們如果從資料面來看, {}
代表表單,也就是 FormGroup
; ''
代表表單裡的子欄位,也就是 FormControl
;那 []
呢?
答案是 ─ FormArray
!
不過 FormArray
不能直接跟 form
元素綁定,唯一可以跟 form
元素綁定的只有 FormGroup
,所以 FormArray
一定要在 FormGroup
裡面,就像這樣:
1 2 3 this .formGroup = this .formBuilder.group({ insuredList: this .formBuilder.array([]) });
這邊要注意的是, FormArray
一定要透過 FormBuilder
或是 FormArray
的建構式來建立,像上面示範的那樣,或是這樣:
1 2 3 this .formGroup = this .formBuilder.group({ insuredList: new FormArray([]) });
絕對不能偷懶寫成這樣:
1 2 3 this .formGroup = this .formBuilder.group({ insuredList: [] });
這樣的話,就會變成普通的 FormControl
囉!切記切記!
接著我們就可以將原本的程式碼修改成用陣列的方式,並把新增被保人、刪除被保人與判斷表單是否有效的函式都補上:
1 2 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 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 @Component ({ }) export class AppComponent implements OnInit { formGroup: FormGroup | undefined ; get formArray(): FormArray { return this .formGroup?.get('insuredList' )! as FormArray; } get isFormInvalid(): boolean { return this .formArray.controls.length === 0 || this .formGroup!.invalid; } constructor (private formBuilder: FormBuilder ) {} ngOnInit(): void { this .formGroup = this .formBuilder.group({ insuredList: this .formBuilder.array([]) }); } addInsured(): void { const formGroup = this .createInsuredFormGroup(); this .formArray.push(formGroup); } deleteInsured(index: number ): void { this .formArray.controls.splice(index, 1 ); this .formArray.updateValueAndValidity(); } submit(): void { } getErrorMessage(key: string , index: number ): string { const formGroup = this .formArray.controls[index]; const formControl = formGroup.get(key); let errorMessage: string ; if (!formControl || !formControl.errors || formControl.pristine) { errorMessage = '' ; } else if (formControl.errors.required) { errorMessage = '此欄位必填' ; } else if (formControl.errors.minlength) { errorMessage = '姓名至少需兩個字以上' ; } else if (formControl.errors.maxlength) { errorMessage = '姓名至多只能輸入十個字' ; } return errorMessage!; } private createInsuredFormGroup(): FormGroup { return this .formBuilder.group({ name: [ '' , [Validators.required, Validators.minLength(2 ), Validators.maxLength(10 )] ], gender: ['' , Validators.required], age: ['' , Validators.required] }); } }
接著我們到 Template 裡,把原本綁定的方式調整一下:
1 2 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 <form *ngIf ="formGroup" [formGroup ]="formGroup" (submit )="submit()" > <ng-container formArrayName ="insuredList" *ngFor ="let control of formArray.controls; let index = index" > <fieldset [formGroupName ]="index" > <legend > 被保人</legend > <p > <label [for ]="'name-' + index" > 姓名:</label > <input type ="text" [id ]="'name-' + index" formControlName ="name" /> <span class ="error" > {{ getErrorMessage("name", index) }}</span > </p > <p > 性別: <input type ="radio" [id ]="'male-' + index" value ="male" formControlName ="gender" /> <label [for ]="'male-' + index" > 男</label > <input type ="radio" [id ]="'female-' + index" value ="female" formControlName ="gender" /> <label [for ]="'female-' + index" > 女</label > </p > <p > <label [for ]="'age-' + index" > 年齡:</label > <select name ="age" [id ]="'age-' + index" formControlName ="age" > <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" > {{ getErrorMessage("age", index) }}</span > </p > <p > <button type ="button" (click )="deleteInsured(index)" > 刪除</button > </p > </fieldset > </ng-container > <p > <button type ="button" (click )="addInsured()" > 新增被保險人</button > <button type ="submit" [disabled ]="isFormInvalid" > 送出</button > </p > </form >
初次看到這種綁定方式的 Angular 初學者可能會傻眼,不過靜下心來看之後你會發現,其實這只是我們所建立的 FormGroup
裡的階層關係,這樣綁定 Angular 才能從一層層的表單之中開始往下找。
如果我們把其他的 HTML 都拿掉的話其實會清楚很多:
1 2 3 <form *ngIf ="formGroup" [formGroup ]="formGroup" (submit )="submit()" > </form >
最外層的這個大家應該都知道,就是我們在 .ts
裡的 formGroup
。
1 2 3 4 5 6 7 8 <form *ngIf ="formGroup" [formGroup ]="formGroup" (submit )="submit()" > <ng-container formArrayName ="insuredList" *ngFor ="let control of formArray.controls; let index = index" > </ng-container > </form >
而這裡呢,就像我們寫靜態表單的時候,會從 FormGroup
裡根據對應的 key
值找到對應的 FormControl
一樣,這裡則是把對應的 FormArray
找出來。
然後再用 *ngFor
的方式,把 FormArray
底下的 AbstractControl
都迴圈出來。
關於 AbstractControl
,它其實是一個抽象類別 ,而 FormGroup
、 FormArray
與 FormControl
這三種類型其實都繼承於這個類別,所以大家不知道有沒有注意到,一般我們在 .ts
裡使用的時候,我們會特別用 as FormControl
或是 as FormArray
的方式來讓編譯器知道現在取得的物件實體是什麼型別,以便後續使用。
想知道更多 AbstractControl
的資訊的話,請參考官方 API 文件: https://angular.io/api/forms/AbstractControl 。
1 2 3 4 5 6 7 8 9 <form *ngIf ="formGroup" [formGroup ]="formGroup" (submit )="submit()" > <ng-container formArrayName ="insuredList" *ngFor ="let control of formArray.controls; let index = index" > <fieldset [formGroupName ]="index" > </fieldset > </ng-container > </form >
最後再用索引值 index
找出對應的 FormGroup
。
而要做這件事情其實要有相對應的階層關係的 HTML 來幫忙,但因為我的 HTML 的階層關係少一層,所以我才會用 ng-container
多做一層階層,好讓我的表單可以順利綁上去。
如果今天你做的 HTML 的階層數是足夠的,就可以不用用 ng-container
多做一層階層,例如把上面的 HTML 改成這樣其實也可以:
1 2 3 4 5 6 7 8 9 <form *ngIf ="formGroup" [formGroup ]="formGroup" (submit )="submit()" > <div formArrayName ="insuredList" *ngFor ="let control of formArray.controls; let index = index" > <fieldset [formGroupName ]="index" > </fieldset > </div > </form >
不過用 ng-container
的好處是這個元素並不會真的出現在畫面上,大家可以視情況斟酌使用。
改完之後就大功告成囉!來看看最後的結果:
本日小結 今天的學習重點主要是在圍繞在 FormArray
上,因為多了這個階層的關係,所以在與 Template 的綁定上看起來會較為複雜一點點。
話雖如此,大家可以拿今天的 template 與昨天的 template 互相比較一下,除了 for
與 id
這兩個屬性因為天生侷限的關係真的沒辦法之外,但 name
的部份就不用再去處理了,還是很方便的。
今天的程式碼我會放在 Github - Branch: day11 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!