細說 Angular 2+ 的表單(二):響應式表單

接灰的電子產品發表於2017-06-13

細說 Angular 2+ 的表單(一):模板驅動型表單

響應式表單

響應式表單乍一看還是很像模板驅動型表單的,但響應式表單需要引入一個不同的模組: ReactiveFormsModule 而不是 FormsModule

import {ReactiveFormsModule} from "@angular/forms";
@NgModule({
  // 省略其他
    imports: [..., ReactiveFormsModule],
  // 省略其他
})
// 省略其他複製程式碼

與模板驅動型表單的區別

接下來我們還是利用前面的例子,用響應式表單的要求改寫一下:

<form [formGroup]="user" (ngSubmit)="onSubmit(user)">
  <label>
    <span>電子郵件地址</span>
    <input type="text" formControlName="email" placeholder="請輸入您的 email 地址">
  </label>
  <div *ngIf="user.get('email').hasError('required') && user.get('email').touched" class="error">
    email 是必填項
  </div>
  <div *ngIf="user.get('email').hasError('pattern') && user.get('email').touched" class="error">
    email 格式不正確
  </div>
  <div>
    <label>
      <span>密碼</span>
      <input type="password" formControlName="password" placeholder="請輸入您的密碼">
    </label>
    <div *ngIf="user.get('password').hasError('required') && user.get('password').touched" class="error">
      密碼是必填項
    </div>
    <label>
      <span>確認密碼</span>
      <input type="password" formControlName="repeat" placeholder="請再次輸入密碼">
    </label>   
    <div *ngIf="user.get('repeat').hasError('required') && user.get('repeat').touched" class="error">
      確認密碼是必填項
    </div>
    <div *ngIf="user.hasError('validateEqual') && user.get('repeat').touched" class="error">
      確認密碼和密碼不一致
    </div>
  </div>
  <div formGroupName="address">
    <label>
      <span>省份</span>
      <select formControlName="province">
        <option value="">請選擇省份</option>
        <option [value]="province" *ngFor="let province of provinces">{{province}}</option>
      </select>
    </label>
    <label>
      <span>城市</span>
      <select formControlName="city">
        <option value="">請選擇城市</option>
        <option [value]="city" *ngFor="let city of (cities$ | async)">{{city}}</option>
      </select>
    </label>
    <label>
      <span>區縣</span>
      <select formControlName="area">
        <option value="">請選擇區縣</option>
        <option [value]="area" *ngFor="let area of (areas$ | async)">{{area}}</option>
      </select>
    </label>
    <label>
      <span>地址</span>
      <input type="text" formControlName="addr">
    </label>
  </div>
  <button type="submit" [disabled]="user.invalid">註冊</button>
</form>複製程式碼

這段程式碼和模板驅動型表單的那段看起來差不多,但是有幾個區別:

  • 表單多了一個指令 [formGroup]="user"
  • 去掉了對錶單的引用 #f="ngForm"
  • 每個控制元件多了一個 formControlName
  • 但同時每個控制元件也去掉了驗證條件,比如 requiredminlength
  • 在地址分組中用 formGroupName="address" 替代了 ngModelGroup="address"

模板上的區別大概就這樣了,接下來我們來看看元件的區別:

import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from "@angular/forms";
@Component({
  selector: 'app-model-driven',
  templateUrl: './model-driven.component.html',
  styleUrls: ['./model-driven.component.css']
})
export class ModelDrivenComponent implements OnInit {

  user: FormGroup;

  ngOnInit() {
    // 初始化表單
    this.user = new FormGroup({
      email: new FormControl('', [Validators.required, Validators.pattern(/([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+.[a-zA-Z]{2,4}/)]),
      password: new FormControl('', [Validators.required]),
      repeat: new FormControl('', [Validators.required]),
      address: new FormGroup({
        province: new FormControl(''),
        city: new FormControl(''),
        area: new FormControl(''),
        addr: new FormControl('')
      })
    });
  }

  onSubmit({value, valid}){
    if(!valid) return;
    console.log(JSON.stringify(value));
  }
}複製程式碼

從上面的程式碼中我們可以看到,這裡的表單( FormGroup )是由一系列的表單控制元件( FormControl )構成的。其實 FormGroup 的建構函式接受的是三個引數: controls(表單控制元件『陣列』,其實不是陣列,是一個類似字典的物件) 、 validator(驗證器) 和 asyncValidator(非同步驗證器) ,其中只有 controls 陣列是必須的引數,後兩個都是可選引數。

// FormGroup 的建構函式
constructor(
  controls: {
    [key: string]: AbstractControl;
  }, 
  validator?: ValidatorFn, 
  asyncValidator?: AsyncValidatorFn
)複製程式碼

我們上面的程式碼中就沒有使用驗證器和非同步驗證器的可選引數,而且注意到我們提供 controls 的方式是,一個 key 對應一個 FormControl 。比如下面的 keypassword,對應的值是 new FormControl('', [Validators.required]) 。這個 key 對應的就是模板中的 formControlName 的值,我們模板程式碼中設定了 formControlName="password" ,而表單控制元件會根據這個 password 的控制元件名來跟蹤實際的渲染出的表單頁面上的控制元件(比如 <input formcontrolname="password">)的值和驗證狀態。

password: new FormControl('', [Validators.required])複製程式碼

那麼可以看出,這個表單控制元件的建構函式同樣也接受三個可選引數,分別是:控制元件初始值( formState )、控制元件驗證器或驗證器陣列( validator )和控制元件非同步驗證器或非同步驗證器陣列( asyncValidator )。上面的那行程式碼中,初始值為空字串,驗證器是『必選』,而非同步驗證器我們沒有提供。

// FormControl 的建構函式
constructor(
  formState?: any, // 控制元件初始值
  validator?: ValidatorFn | ValidatorFn[], // 控制元件驗證器或驗證器陣列
  asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] // 控制元件非同步驗證器或非同步驗證器陣列
)複製程式碼

由此可以看出,響應式表單區別於模板驅動型表單的的主要特點在於:是由元件類去建立、維護和跟蹤表單的變化,而不是依賴模板。

那麼我們是否在響應式表單中還可以使用 ngModel 呢?當然可以,但這樣的話表單的值會在兩個不同的位置儲存了: ngModel 繫結的物件和 FormGroup ,這個在設計上我們一般是要避免的,也就是說盡管可以這麼做,但我們不建議這麼做。

FormBuilder 快速構建表單

上面的表單構造起來雖然也不算太麻煩,但是在表單專案逐漸多起來之後還是一個挺麻煩的工作,所以 Angular 提供了一種快捷構造表單的方式 -- 使用 FormBuilder。

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
@Component({
  selector: 'app-model-driven',
  templateUrl: './model-driven.component.html',
  styleUrls: ['./model-driven.component.css']
})
export class ModelDrivenComponent implements OnInit {

  user: FormGroup;

  constructor(private fb: FormBuilder) {
  }

  ngOnInit() {
    // 初始化表單
    this.user = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', Validators.required],
      repeat: ['', Validators.required],
      address: this.fb.group({
        province: [],
        city: [],
        area: [],
        addr: []
      })
    });
  }
  // 省略其他部分
}複製程式碼

使用 FormBuilder 我們可以無需顯式宣告 FormControl 或 FormGroup 。 FormBuilder 提供三種型別的快速構造: control , grouparray ,分別對應 FormControl, FormGroup 和 FormArray。 我們在表單中最常見的一種是通過 group 來初始化整個表單。上面的例子中,我們可以看到 group 接受一個字典物件作為引數,這個字典中的 key 就是這個 FormGroup 中 FormControl 的名字,值是一個陣列,陣列中的第一個值是控制元件的初始值,第二個是同步驗證器的陣列,第三個是非同步驗證器陣列(第三個並未出現在我們的例子中)。這其實已經在隱性的使用 FormBuilder.control 了,可以參看下面的 FormBuilder 中的 control 函式定義,其實 FormBuilder 利用我們給出的值構造了相對應的 control

control(
    formState: Object, 
    validator?: ValidatorFn | ValidatorFn[], 
    asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[]
    ): FormControl;複製程式碼

此外還值得注意的一點是 address 的處理,我們可以清晰的看到 FormBuilder 支援巢狀,遇到 FormGroup 時僅僅需要再次使用 this.fb.group({...}) 即可。這樣我們的表單在擁有大量的表單項時,構造起來就方便多了。

自定義驗證

對於響應式表單來說,構造一個自定義驗證器是非常簡單的,比如我們上面提到過的的驗證 密碼重複輸入密碼 是否相同的需求,我們在響應式表單中來試一下。

  validateEqual(passwordKey: string, confirmPasswordKey: string): ValidatorFn {
    return (group: FormGroup): {[key: string]: any} => {
      const password = group.controls[passwordKey];
      const confirmPassword = group.controls[confirmPasswordKey];
      if (password.value !== confirmPassword.value) {
        return { validateEqual: true };
      }
      return null;
    }
  }複製程式碼

這個函式的邏輯比較簡單:我們接受兩個字串(是 FormControl 的名字),然後返回一個 ValidatorFn。但是這個函式裡面就奇奇怪怪的,
比如 (group: FormGroup): {[key: string]: any} => {...} 是什麼意思啊?還有,這個 ValidatorFn 是什麼鬼?我們來看一下定義:

export interface ValidatorFn {
    (c: AbstractControl): ValidationErrors | null;
}複製程式碼

這樣就清楚了, ValidatorFn 是一個物件定義,這個物件中有一個方法,此方法接受一個 AbstractControl 型別的引數(其實也就是我們的 FormControl,而 AbstractControl 為其父類),而這個方法還要返回 ValidationErrors ,這個 ValidationErrors 的定義如下:

export declare type ValidationErrors = {
    [key: string]: any;
};複製程式碼

回過頭來再看我們的這句 (group: FormGroup): {[key: string]: any} => {...},大家就應該明白為什麼這麼寫了,我們其實就是在返回一個 ValidatorFn 型別的物件。只不過我們利用 javascript/typescript 物件展開的特性把 ValidationErrors 寫成了 {[key: string]: any}

弄清楚這個函式的邏輯後,我們怎麼使用呢?非常簡單,先看程式碼:

    this.user = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', Validators.required],
      repeat: ['', Validators.required],
      address: this.fb.group({
        province: [],
        city: [],
        area: [],
        addr: []
      })
    }, {validator: this.validateEqual('password', 'repeat')});複製程式碼

和最初的程式碼相比,多了一個引數,那就是 {validator: this.validateEqual('password', 'repeat')}。FormBuilder 的 group 函式接受兩個引數,第一個就是那串長長的,我們叫它 controlsConfig,用於表單控制元件的構造,以及每個表單控制元件的驗證器。但是如果一個驗證器是要計算多個 field 的話,我們可以把它作為整個 group 的驗證器。所以 FormBuilder 的 group 函式還接收第二個引數,這個引數中可以提供同步驗證器或非同步驗證器。同樣還是一個字典物件,是同步驗證器的話,key 寫成 validator,非同步的話寫成 asyncValidator

現在我們可以儲存程式碼,啟動 ng serve 到瀏覽器中看一下結果了:

細說 Angular 2+ 的表單(二):響應式表單
響應式表單對於多值驗證的處理

FormArray 有什麼用?

我們在購物網站經常遇到需要維護多個地址,因為我們有些商品希望送到公司,有些需要送到家裡,還有些給父母採購的需要送到父母那裡。這就是一個典型的 FormArray 可以派上用場的場景。所有的這些地址的結構都是一樣的,有省、市、區縣和街道地址,那麼對於處理這樣的場景,我們來看看在響應式表單中怎麼做。

首先,我們需要把 HTML 模板改造一下,現在的地址是多項了,所以我們需要在原來的地址部分外面再套一層,並且宣告成 formArrayName="addrs"。 FormArray 顧名思義是一個陣列,所以我們要對這個控制元件陣列做一個迴圈,然後讓每個陣列元素是 FormGroup,只不過這次我們的 [formGroupName]="i" 是讓 formGroupName 等於該陣列元素的索引。

<div formArrayName="addrs">
    <button (click)="addAddr()">Add</button>
    <div *ngFor="let item of user.controls['addrs'].controls; let i = index;">
      <div [formGroupName]="i">
        <label>
          <span>省份</span>
          <select formControlName="province">
            <option value="">請選擇省份</option>
            <option [value]="province" *ngFor="let province of provinces">{{province}}</option>
          </select>
        </label>
        <label>
          <span>城市</span>
          <select formControlName="city">
            <option value="">請選擇城市</option>
            <option [value]="city" *ngFor="let city of (cities$ | async)">{{city}}</option>
          </select>
        </label>
        <label>
          <span>區縣</span>
          <select formControlName="area">
            <option value="">請選擇區縣</option>
            <option [value]="area" *ngFor="let area of (areas$ | async)">{{area}}</option>
          </select>
        </label>
        <label>
          <span>地址</span>
          <input type="text" formControlName="street">
        </label>
      </div>
    </div>
  </div>複製程式碼

改造好模板後,我們需要在類檔案中也做對應處理,去掉原來的 address: this.fb.group({...}),換成 addrs: this.fb.array([]) 。而

this.user = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
  password: ['', Validators.required],
  repeat: ['', Validators.required],
  addrs: this.fb.array([])
}, {validator: this.validateEqual('password', 'repeat')});複製程式碼

但這樣我們是看不到也增加不了新的地址的,因為我們還沒有處理新增的邏輯呢,下面我們就新增一下:其實就是建立一個新的 FormGroup,然後加入 FormArray 陣列中。

  addAddr(): void {
    (<FormArray>this.user.controls['addrs']).push(this.createAddrItem());
  }

  private createAddrItem(): FormGroup {
    return this.fb.group({
      province: [],
      city: [],
      area: [],
      street: []
    })
  }複製程式碼

到這裡我們的結構就建好了,儲存後,到瀏覽器中去試試新增多個地址吧!

細說 Angular 2+ 的表單(二):響應式表單
FormArray 處理結構相同的多組表單項

響應式表單的優勢

首先是可測試能力。模板驅動型表單進行單元測試是比較困難的,因為驗證邏輯是寫在模板中的。但驗證器的邏輯單元測試對於響應式表單來說就非常簡單了,因為你的驗證器無非就是一個函式而已。

當然除了這個優點,我們對錶單可以有完全的掌控:從初始化表單控制元件的值、更新和獲取表單值的變化到表單的驗證和提交,這一系列的流程都在程式邏輯控制之下。

而且更重要的是,我們可以使用函式響應式程式設計的風格來處理各種表單操作,因為響應式表單提供了一系列支援 Observable 的介面 API 。那麼這又能說明什麼呢?有什麼用呢?

首先是無論表單本身還是控制元件都可以看成是一系列的基於時間維度的資料流了,這個資料流可以被多個觀察者訂閱和處理,由於 valueChanges 本身是個 Observable,所以我們就可以利用 RxJS 提供的豐富的操作符,將一個對資料驗證、處理等的完整邏輯清晰的表達出來。當然現在我們不會對 RxJS 做深入的討論,後面有專門針對 RxJS 進行講解的章節。

this.form.valueChanges
        .filter((value) => this.user.valid)
        .subscribe((value) => {
           console.log("現在時刻表單的值為 ",JSON.stringify(value));
        });複製程式碼

上面的例子中,我們取得表單值的變化,然後過濾掉表單存在非法值的情況,然後輸出表單的值。這只是非常簡單的一個 Rx 應用,隨著邏輯複雜度的增加,我們後面會見證 Rx 卓越的處理能力。

相關文章