我的新書AI 職場超神助手:ChatGPT 與生成式 AI 一鍵搞定工作難題的教材投影片已製作完成
歡迎各位有需要的教師和博碩文化索取教材

[功能介紹-10] Reactive Forms (Model-Driven Forms) 

Template-Driven Forms與Model-Driven Forms的比較

Reactive forms

Reactive forms的驗證大多是直接寫在controller裡的,會是一個明確的、非UI的data flowing。
Reactive forms的reactive patterns可以讓測試與驗證更加簡單。

使用Reactive forms可以用一個樹狀的控制物件來binding到表單template的元件上,這讓所有驗證的程式碼都集中在一起,方便維護與管理,在撰寫單元測試時也會較為容易。

使用Model-Driven Forms也較符合reactive programming的概念(延伸閱讀:Functional Reactive Programming 的入門心得

Template-driven forms

Template-driven forms是將組件驗證控制的功能寫在像是<input>或<select>的標籤內,並利用ngModel來確認是否輸入了合法的內容。
使用表單驅動驗證不需要自己創建control objects,因為angular已經為我們建好了。
ngModel會處理使用者改變與輸入表單的事件,並更新ngModel裡面的可變數據,讓我們可以去處理後續的事。
也因此ngModel並不是ReactiveFormsModule的一部份。

這代表著使用表單驅動驗證,我們需要撰寫的程式碼更少。
但是如果我們的表單需要很複雜的驗證步驟並且要顯示很多不同的錯誤訊息時,使用表單驅動驗證會使事情變得更複雜並難以維護。

最大的差異,同步與非同步

Reactive forms是同步的而Template-driven forms為非同步處理,是這兩者間最大的差異。

對Reactive forms來說,所有表單的資料是在code裡以tree的方式來呈現,所以在任一個節點可以取得其他表單的資料,並且這些資料是即時同步被更新的。我們也可以在使用者修改了某個input的值時,去為使用者自動update另一個input內的預設值,這是因為所有資料都是隨時可取得的。

Template-driven forms在每一個表單元件各自透過directive委派檢查的功能,為了避免檢查後修改而造成檢查失效的問題,directive會在更多的時後去檢查輸入的值的正確性,因此並沒有辦法立即的得到回應,而需要一小段的時間才有辦法得到使用者輸入的值是否合法的回應。這會讓我們在撰寫單元測試時更加複雜,我們會需要利用setTimeout去讓取得的檢查結果是正確的

使用Reactive Forms

Reactive Forms的功能封裝在ReactiveFormsModule中,和FormsModule同樣在@angular/forms之下。
如果要使用Reactive Forms需要使用下面的程式碼

import { ReactiveFormsModule } from '@angular/forms';

Reactive Forms的四個重要成員

  • AbstractControl:AbstractControl是FormControl、FormGroup、FormArray這三個實例表單類的抽象基類。它提供了他們的通用行為以及屬性,例如observable。
  • FormControl:在單個表單元件中檢查值並驗證狀態(比如input、select等等)。
  • FormGroup一組值與驗證狀態(FormControl),其屬性包含了它們的子控件。例如一個form表單就是一個FormGroup。
  • FormArray:用索引的方式去追蹤檢查表單的驗證狀態。

Add a FormGroup

首先要新增FormGroup所需使用的類別

import { Component }              from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';

然後創建這個Group,並定義裡面的驗證元素

export class HeroDetailComponent2 {
  heroForm = new FormGroup ({
    name: new FormControl()
  });
}

接著,在template裡面的form裡指定這個form要使用heroForm來做表單驗證,並且在input裡面指定他的formControlName

<h2>Hero Detail</h2>
<h3><i>FormControl in a FormGroup</i></h3>
<form &#91;formGroup&#93;="heroForm" novalidate>
  <div class="form-group">
    <label class="center-block">Name:
      <input class="form-control" formControlName="name">
    </label>
  </div>
</form>

form標籤下的novalidate屬性,是為了要防止瀏覽器自己執行native的驗證。

<form &#91;formGroup&#93;="heroForm" novalidate>

[formGroup]=”heroForm”則是將template內的form元件與controller裡所創的formGroup做關連

<input class="form-control" formControlName="name">

這個則是將input與formGroup下名為name的formControl做關連。

註:bootstrap的form-group以及form-control與angular完全無關。下面是bootstrap為我們設計的form表單樣式範例,但是這只是css,沒辦法讓組件與控制器結合。

<form>
  <div class="form-group">
    <label for="formGroupExampleInput">Example label</label>
    <input type="text" class="form-control" id="formGroupExampleInput" placeholder="Example input">
  </div>
  <div class="form-group">
    <label for="formGroupExampleInput2">Another label</label>
    <input type="text" class="form-control" id="formGroupExampleInput2" placeholder="Another input">
  </div>
</form>

Introduction to FormBuilder

FormBuilder可以減少我們在創建formGroup時有太多重覆的定義,要使用要先import必要的檔案

import { Component }              from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

使用formBuild大致要做的事如下:

  • 宣告heroForm為FormGroup
  • 在初始化元件時inject FormBuilder
  • 創建form控件時需要另外去呼叫函數createForm,使用注入的FormBuilder來創建formControl
  • 在創建formGroup時,用this.fb.group來宣告這個formGroup裡所有的formControl。
export class HeroDetailComponent3 {
  heroForm: FormGroup; // <--- 宣告heroForm為FormGroup

  constructor(private fb: FormBuilder) { // <--- 注入FormBuilder
    this.createForm();
  }

  createForm() {
    this.heroForm = this.fb.group({
      name: '', // <--- 建立一個名為name,預設值為''的formControl
    });
  }
}&#91;/code&#93;
formBuild的宣告方式如上,name控件由其初始數據值(一個空字符串)定義。
<h3>使用驗證器</h3>
首先要先import該驗證器
[code lang="js"]import { Component }                          from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

然後在建立formControl時指定使用該驗證器

this.heroForm = this.fb.group({
  name: ['', Validators.required ],
});

Nested FormGroups

有時我們在做地址輸入框時,會有如國家、區、鄉、市、街、郵遞區號等不同的輸入欄位,但他們應該是一個group,這時候就可以用nested formGroup。透過這樣的結構層次,可以讓我們在追蹤表格狀態更為容易清楚。

export class HeroDetailComponent5 {
  heroForm: FormGroup;
  states = states;

  constructor(private fb: FormBuilder) {
    this.createForm();
  }

  createForm() {
    this.heroForm = this.fb.group({ // <-- the parent FormGroup
      name: &#91;'', Validators.required &#93;,
      address: this.fb.group({ // <-- the child FormGroup
        street: '',
        city: '',
        state: '',
        zip: ''
      }),
      power: '',
      sidekick: ''
    });
  }
}&#91;/code&#93;
我們用一個div將整個地址的區塊包起來,並用formGroupName="address"來與heroForm裡的address做連結
&#91;code lang="html"&#93;<div formGroupName="address" class="well well-lg">
  <h4>Secret Lair</h4>
  <div class="form-group">
    <label class="center-block">Street:
      <input class="form-control" formControlName="street">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">City:
      <input class="form-control" formControlName="city">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">State:
      <select class="form-control" formControlName="state">
        <option *ngFor="let state of states" &#91;value&#93;="state">{{state}}</option>
      </select>
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Zip Code:
      <input class="form-control" formControlName="zip">
    </label>
  </div>
</div>

檢視FormControl裡的屬性

我們可以用下面的方式來將formControl裡可使用的屬性都印出來

<p>Form value: {{ heroForm.value | json }}</p>

基本上會有下面四個屬性可以讓我們使用

屬性 描述
myControl.value FormControl使用者輸入的值
myControl.status 這個FormControl的驗證結果. 可能的值有: VALIDINVALIDPENDING, or DISABLED.
myControl.pristine 使用者是否有在UI上更動過這個元素,假如沒有的話會是true。相反的屬性為myControl.dirty.
myControl.untouched 使用者尚未輸入並且從未觸發過blur event時為true。相反的屬性為 myControl.touched.

The data model and the form model

以往我們在創建資料類型時是像這樣子的

export class Hero {
  id = 0;
  name = '';
  addresses: Address[];
}

export class Address {
  street = '';
  city   = '';
  state  = '';
  zip    = '';
}

但是我們在創建formGroup是這樣子的

this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group({
    street: '',
    city: '',
    state: '',
    zip: ''
  }),
  power: '',
  sidekick: ''
});

我們可以直接利用class來創建formControl

this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group(new Address()), // <-- a FormGroup with a new address
  power: '',
  sidekick: ''
});&#91;/code&#93;
<h3>使用setValue和patchValue為表單填入初始值</h3>
在上面data model和form model的介紹範例中,可以看到Hero與formGroup建立heroForm模型有兩個顯著的區別:
<ul>
	<li>Hero class有id,formGroup沒有。</li>
	<li>Hero class的地址是一個陣列</li>
</ul>
但是我們可以利用setValue來更簡單的將一個class的資料填進表單中。
[code lang="js"]this.heroForm.setValue({
  name:    this.hero.name,
  address: this.hero.addresses[0] || new Address()//這是因為form元件只能顯示一個地址,如果class內容沒有值時,要預設新建立一個Address物件
});

也可以使用patchValue來將單一的值填入表單裡

this.heroForm.patchValue({
  name: this.hero.name
});

如果我們要做一個修改hero資料的列表,當點下某個hero時就可以修改該hero的資料

<nav>
  <a *ngFor="let hero of heroes | async" (click)="select(hero)">{{hero.name}}</a>
</nav>

<div *ngIf="selectedHero">
  <app-hero-detail &#91;hero&#93;="selectedHero"></app-hero-detail>
</div>

然後在controller裡面去監聽ngOnChange事件並且用setValue來設定要修改的值

ngOnChanges() {
  this.heroForm.reset({
    name: this.hero.name,
    address: this.hero.addresses[0] || new Address()
  });
}

會需要使用reset是為了要清除前一個hero的資料

使用FormArray來呈現一個FormGroups

如果一個hero可能需要有多組的地址時,就會需要使用formArray。
原本我們是這樣定義Address的

this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group(new Address()), // <-- a FormGroup with a new address
  power: '',
  sidekick: ''
});&#91;/code&#93;
使用formArray則變成這樣
&#91;code lang="js"&#93;this.heroForm = this.fb.group({
  name: &#91;'', Validators.required &#93;,
  secretLairs: this.fb.array(&#91;&#93;), // <-- secretLairs as an empty FormArray
  power: '',
  sidekick: ''
});&#91;/code&#93;
可以用下面的function將很多組的address設定進去formArray成為預設值
&#91;code lang="js"&#93;setAddresses(addresses: Address&#91;&#93;) {
  const addressFGs = addresses.map(address => this.fb.group(address));
  const addressFormArray = this.fb.array(addressFGs);
  this.heroForm.setControl('secretLairs', addressFormArray);
}

要取得formArray可以撰寫下面的方法

get secretLairs(): FormArray {
  return this.heroForm.get('secretLairs') as FormArray;
};

而顯示方式如下:

<div formArrayName="secretLairs" class="well well-lg">
  <div *ngFor="let address of secretLairs.controls; let i=index" &#91;formGroupName&#93;="i" >
    <!-- The repeated address template -->
  </div>
</div>

完整內容如下:

<div formArrayName="secretLairs" class="well well-lg">
  <div *ngFor="let address of secretLairs.controls; let i=index" &#91;formGroupName&#93;="i" >
    <!-- The repeated address template -->
    <h4>Address #{{i + 1}}</h4>
    <div style="margin-left: 1em;">
      <div class="form-group">
        <label class="center-block">Street:
          <input class="form-control" formControlName="street">
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">City:
          <input class="form-control" formControlName="city">
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">State:
          <select class="form-control" formControlName="state">
            <option *ngFor="let state of states" &#91;value&#93;="state">{{state}}</option>
          </select>
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">Zip Code:
          <input class="form-control" formControlName="zip">
        </label>
      </div>
    </div>
    <br />
    <!-- End of the repeated address template -->
  </div>
</div>

要為這個hero新增一個地址可以用下面這個方法

addLair() {
  this.secretLairs.push(this.fb.group(new Address()));
}

按下增加地址按鈕時呼叫這個方法

<button (click)="addLair()" type="button">Add a Secret Lair</button>

將formControl的資料用深層複製存回class裡的方法
prepareSaveHero(): Hero {
const formModel = this.heroForm.value;

// deep copy of form model lairs
const secretLairsDeepCopy: Address[] = formModel.secretLairs.map(
(address: Address) => Object.assign({}, address)
);

// return new `Hero` object containing a combination of original hero value(s)
// and deep copies of changed form model values
const saveHero: Hero = {
id: this.hero.id,
name: formModel.name as string,
// addresses: formModel.secretLairs // <-- bad! addresses: secretLairsDeepCopy }; return saveHero; }[/code]

參考資料


17年資歷女工程師,專精於動畫、影像辨識以及即時串流程式開發。經常組織活動,邀請優秀的女性分享她們的技術專長,並在眾多場合分享自己的技術知識,也活躍於非營利組織,辦理活動來支持特殊兒及其家庭。期待用技術改變世界。

如果你認同我或想支持我的努力,歡迎請我喝一杯咖啡!讓我更有動力分享知識!