今天要來用 Reactive Forms 的方式再來實作一次昨天的表單。
具體的規格需求跟昨天差不多,如下所示:
姓名至少需兩個字以上
姓名最多只能十個字
此欄位為必填
規格需求看清楚之後,我們就來開始實作吧!
首先我們一樣先準備好基本的 HTML :
<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>
未經美化的畫面跟昨天長得一樣:
接著跟昨天一樣先把它當成靜態表單來準備相關的屬性與方法:
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 {
/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;
/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*
* @param {FormBuilder} formBuilder
* @memberof ReactiveFormsAsyncInsuredComponent
*/
constructor(private formBuilder: FormBuilder) {}
/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}
/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
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!;
}
/**
* 綁定在表單上,當按下送出按鈕時會觸發此函式
*
* @memberof TemplateDrivenFormsAsyncInsuredComponent
*/
submit(): void {
// do submit...
}
}
準備好相關的屬性和方法之後,我們直接把他們跟 Template 綁定:
<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
裡面,就像這樣:
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});
這邊要注意的是, FormArray
一定要透過 FormBuilder
或是 FormArray
的建構式來建立,像上面示範的那樣,或是這樣:
this.formGroup = this.formBuilder.group({
insuredList: new FormArray([])
});
絕對不能偷懶寫成這樣:
this.formGroup = this.formBuilder.group({
insuredList: []
});
這樣的話,就會變成普通的 FormControl
囉!切記切記!
接著我們就可以將原本的程式碼修改成用陣列的方式,並把新增被保人、刪除被保人與判斷表單是否有效的函式都補上:
@Component({
// 省略...
})
export class AppComponent implements OnInit {
/**
* 綁定在表單上
*
* @type {(FormGroup | undefined)}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
formGroup: FormGroup | undefined;
/**
* 用以取得 FormArray
*
* @readonly
* @type {FormArray}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get formArray(): FormArray {
return this.formGroup?.get('insuredList')! as FormArray;
}
/**
* 綁定在送出按鈕上,判斷表單是不是無效
*
* @readonly
* @type {boolean}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
get isFormInvalid(): boolean {
return this.formArray.controls.length === 0 || this.formGroup!.invalid;
}
/**
* 透過 DI 取得 FromBuilder 物件,用以建立表單
*
* @param {FormBuilder} formBuilder
* @memberof ReactiveFormsAsyncInsuredComponent
*/
constructor(private formBuilder: FormBuilder) {}
/**
* 當 Component 初始化的時候初始化表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
ngOnInit(): void {
this.formGroup = this.formBuilder.group({
insuredList: this.formBuilder.array([])
});
}
/**
* 新增被保人
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
addInsured(): void {
const formGroup = this.createInsuredFormGroup();
this.formArray.push(formGroup);
}
/**
* 刪除被保人
*
* @param {number} index
* @memberof ReactiveFormsAsyncInsuredComponent
*/
deleteInsured(index: number): void {
this.formArray.controls.splice(index, 1);
this.formArray.updateValueAndValidity();
}
/**
* 送出表單
*
* @memberof ReactiveFormsAsyncInsuredComponent
*/
submit(): void {
// do login...
}
/**
* 透過欄位的 Errors 來取得對應的錯誤訊息
*
* @param {string} key
* @param {number} index
* @return {*} {string}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
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
* @return {*} {FormGroup}
* @memberof ReactiveFormsAsyncInsuredComponent
*/
private createInsuredFormGroup(): FormGroup {
return this.formBuilder.group({
name: [
'',
[Validators.required, Validators.minLength(2), Validators.maxLength(10)]
],
gender: ['', Validators.required],
age: ['', Validators.required]
});
}
}
接著我們到 Template 裡,把原本綁定的方式調整一下:
<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 都拿掉的話其實會清楚很多:
<form *ngIf="formGroup" [formGroup]="formGroup" (submit)="submit()">
<!-- 其他省略 -->
</form>
最外層的這個大家應該都知道,就是我們在 .ts
裡的 formGroup
。
<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 。
<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 改成這樣其實也可以:
<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 上供大家參考,建議大家在看我的實作之前,先按照需求規格自己做一遍,之後再跟我的對照,看看自己的實作跟我的實作不同的地方在哪裡、有什麼好處與壞處,如此反覆咀嚼消化後,我相信你一定可以進步地非常快!
如果有任何的問題或是回饋,也都非常歡迎留言給我讓我知道噢!
get formArray(): FormArray {
return this.formGroup?.get('insuredList')! as FormArray;
}
想請問這裡的 !
是一定要加的嗎?
如果你專案所採用的 Angular 版本高於或等於 v12 ,抑或者是你有啟用 TypeScript 的 strict mode 的話,建議是需要,不然你會需要在其他的地方加 ?
或者是 !
原來如此!我剛剛 fork 程式碼下來,刪掉 !
好像沒有跳出提醒,我再檢查一下我的設定好了 :)
我剛試了一下,沒有跳提醒的原因主要是因為我有強制轉型成 FormArray
,以這個例子來說倒是真的可以不用加沒錯