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

接灰的電子產品發表於2017-04-24

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

摘要

在企業應用開發時,表單是一個躲不過去的事情,和麵向消費者的應用不同,企業領域的開發中,表單的使用量是驚人的。這些表單的處理其實是一個挺複雜的事情,比如有的是涉及到多個 Tab 的表單,有的是嚮導形式多個步驟的,各種複雜的驗證邏輯和時不時需要彈出的對話方塊等等。筆者試圖在這一系列文章中對 Angular 中的表單處理做一個相對完整的梳理。

Angular 中提供兩種型別的表單處理機制,一種叫模版驅動型(Template Driven)的表單,另一種叫模型驅動型表單( Model Driven ),這後一種也叫響應式表單 ( Reactive Forms ),由於模版驅動中有一個 ngModel 的指令,容易和這裡說的模型驅動混淆,所以在我們的文章中叫後一種說法:響應式表單。

第一篇主要介紹模版驅動型的表單。

號外

本文評論區會抽出5位童鞋,贈送筆者的 《Angular 從零到一》紙書,機不可失,大家踴躍發言哦。

模版驅動的表單

模版驅動的表單和 AngularJS 對於表單的處理類似,把一些指令(比如 ngModel )、資料值和行為約束(比如 requireminlength 等等)繫結到模版中(模版就是元件後設資料 @Component 中定義的那個 template ),這也是模版驅動這個叫法的來源。總體來說,這種型別的表單通過繫結把很多工作交給了模版。

模版驅動的例子

還是用例子來說話,比如我們有一個使用者註冊的表單,使用者名稱就是 email ,還需要填的資訊有:住址、密碼和重複密碼。這個應該是比較常見的一個註冊時需要的資訊了。那麼我們第一步來建立領域模型:

// src/app/domain/index.ts
export interface User {
  // 新的使用者id一般由伺服器自動生成,所以可以為空,用 ? 標示
  id?: string; 
  email: string;
  password: string;
  repeat: string;
  address: Address;
}

export interface Address {
  province: string; // 省份
  city: string; // 城市
  area: string; // 區縣
  addr: string; // 詳細地址
}複製程式碼

接下來我們建立模版檔案,一個最簡單的 HTML 模版,先不增加任何的繫結或事件處理:

<!-- template-driven.component.html -->
<form novalidate>
  <label>
    <span>電子郵件地址</span>
    <input
      type="text"
      name="email"
      placeholder="請輸入您的 email 地址">
  </label>
  <div>
    <label>
      <span>密碼</span>
      <input
        type="password"
        name="password"
        placeholder="請輸入您的密碼">
    </label>
    <label>
      <span>確認密碼</span>
      <input
        type="password"
        name="repeat"
        placeholder="請再次輸入密碼">
    </label>
  </div>
  <div >
    <label>
      <span>省份</span>
      <select name="province">
        <option value="">請選擇省份</option>
      </select>
    </label>
    <label>
      <span>城市</span>
      <select name="city">
        <option value="">請選擇城市</option>
      </select>
    </label>
    <label>
      <span>區縣</span>
      <select name="area">
        <option value="">請選擇區縣</option>
      </select>
    </label>
    <label>
      <span>地址</span>
      <input type="text" name="addr">
    </label>
  </div>
  <button type="submit">註冊</button>
</form>複製程式碼

渲染之後的效果就像下面這樣:

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

資料繫結

對於模版驅動型的表單處理,我們首先需要在對應的模組中引入 FormsModule ,這一點千萬不要忘記了。

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from "@angular/forms";
import { TemplateDrivenComponent } from './template-driven/template-driven.component';

@NgModule({
  imports: [
    CommonModule,
    FormsModule
  ],
  exports: [TemplateDrivenComponent],
  declarations: [TemplateDrivenComponent]
})
export class FormDemoModule { }複製程式碼

進行模版驅動型別的表單處理的一個必要步驟就是建立資料的雙向繫結,那麼我們需要在元件中建立一個型別為 User 的成員變數並賦初始值。

// template-driven.component.ts
// 省略後設資料和匯入的類庫資訊
export class TemplateDrivenComponent implements OnInit {

  user: User = {
    email: '',
    password: '',
    repeat: '',
    address: {
      province: '',
      city: '',
      area: '',
      addr: ''
    }
  };
  // 省略其他部分
}複製程式碼

有了這樣一個成員變數之後,我們在元件模版中就可以使用 ngModel 進行繫結了。

令人困惑的 ngModel

我們在 Angular 中可以使用三種形式的 ngModel 表示式: ngModel , [ngModel][(ngModel)]。但無論那種形式,如果你要使用 ngModel 就必須為該控制元件(比如下面的 input )指定一個 name 屬性,如果你忘記新增 name 的話,多半你會看到下面這樣的錯誤:

ERROR Error: Uncaught (in promise): Error: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions.複製程式碼

ngModel 和 FormControl

假如我們使用的是 ngModel ,沒有任何中括號小括號的話,這代表著我們建立了一個 FormControl 的例項,這個例項將會跟蹤值的變化、使用者的互動、驗證狀態以及保持檢視和領域物件的同步等工作。

<input
  type="text"
  name="email"
  placeholder="請輸入您的 email 地址"
  ngModel>複製程式碼

如果我們將這個控制元件放在一個 Form 表單中, ngModel 會自動將這個 FormControl 註冊為 Form 的子控制元件。下面的例子中我們在 <form> 中加上了 ngForm 指令,宣告這是一個 Angular 可識別的表單,而 ngModel 會將 <input> 註冊成表單的子控制元件,這個子控制元件的名字就是 email,而且 ngModel 會基於這個子控制元件的值去繫結表單的的值,這也是為什麼需要顯式宣告 name 的原因。

其實在我們匯入 FormsModule 的時候,所有的 <form> 標籤都會預設的被認為是一個 NgForm ,因此我們並不需要顯式的在標籤中寫 ngForm 這個指令。

<!-- ngForm 並不需要顯示宣告,任何 <form> 標籤預設都是 ngForm -->
<form novalidate ngForm>
  <input
    type="text"
    name="email"
    placeholder="請輸入您的 email 地址"
    ngModel>
</form>複製程式碼

這一切現在都是不可見的,所以大家可能還是有些困惑,那麼下面我們將其“視覺化”,這需要我們引用一下表單物件,所以我們使用 #f="ngForm" 以便我們可以在模版中輸出表單的一些特性。

<!-- 使用 # 把表單物件匯出到 f 這個可引用變數中 -->
<form novalidate #f="ngForm">
  ...
</form>
<!-- 將表單的值以 JSON 形式輸出 -->
{{f.value | json}}複製程式碼

這時如果我們在 email 中輸入 sss ,可以看到下圖的以 JSON 形式出現的表單值:

細說 Angular 2+ 的表單(一):模板驅動型表單
控制元件的輸入值同步到了表單的值中

單向資料繫結

那麼接下來,我們看看 [ngModel] 有什麼用?如果我們想給控制元件設定一個初始值怎麼辦呢,這時就需要進行一個單向繫結,方向是從元件到檢視。我們可以做的是在初始化 User 的時候,將 email 屬性設定成 wang@163.com

user: User = {
    email: 'wang@163.com',
    ...
  };複製程式碼

而且在模版中使用 [ngModel]="user.email" 進行單向繫結,這個語法其實和普通的屬性繫結是一樣的,用中括號標示這是一個要進行資料繫結的屬性,等號右邊是需要繫結的值(這裡是 user.email )。那麼我們就可以得到下面這樣的輸出了, email 的初始值被繫結成功!

細說 Angular 2+ 的表單(一):模板驅動型表單
單向資料繫結

雙向資料繫結

但上面的例子存在一個問題,資料的繫結是單向的,也就是說,在輸入框進行輸入的時候,我們的 user 的值不會隨之改變的。為了更好的說明,我們將 user 和 表單的值同時輸出

<div>
  <span>user: </span> {{user | json}}
</div>
<div>
  <span>表單:</span> {{f.value | json}}
</div>複製程式碼

此時我們將預設的電子郵件改成 wang@gmail.com 的話,表單的值是改變了,但 user 並未改變。

細說 Angular 2+ 的表單(一):模板驅動型表單
輸入的值影響了表單,但不會影響領域物件

如果我們希望的是在輸入時,這個輸入的值也反向的影響我們的 user 物件的值的話,那就需要用到雙向繫結了,也就是 [(ngModel)] 需要上場了。

細說 Angular 2+ 的表單(一):模板驅動型表單
表單和領域物件的值保持了同步

無論如何,這個 [()] 表達真是很奇怪的樣子,其實這個表達是一個語法糖。只要我們知道下面的兩種寫法是等價的,我們就會很清楚的理解了:用這個語法糖你就不用既寫資料繫結又寫事件繫結了。

<input [(ngModel)]="user.email">
<input [ngModel]="user.email"` (ngModelChange)="user.email = $event">複製程式碼

ngModelGroup 是什麼鬼?

如果我們仔細觀察上面的輸出的話,會發現一個問題: user 中是有一個巢狀物件 address 的,而表單中沒有巢狀物件的。如果要實現表單中的結構和領域物件的結構一致的話,我們就得請出 ngModelGroup 了。ngModelGroup 會建立並繫結一個 FormGroup 到該 DOM 元素。 FormGroup 又是什麼呢?簡單來說,是一組 FormControl。

  <!-- 使用 ngModelGroup 來建立並繫結 FormGroup  -->
  <div ngModelGroup="address">
    <label>
      <span>省份</span>
      <select name="province" (change)="onProvinceChange()" [(ngModel)]="user.address.province">
        <option value="">請選擇省份</option>
        <option [value]="province" *ngFor="let province of provinces">{{province}}</option>
      </select>
    </label>
    <!-- 省略其他部分 -->
  </div>複製程式碼

這樣的話,我們再來看一下輸出,現在就完全一致了:

細說 Angular 2+ 的表單(一):模板驅動型表單
表單和領域物件的結構也完全一致了

資料驗證

模版驅動型的表單的驗證也是主要由模版來處理的,在看怎麼使用之前,需要界定一下驗證規則:

  • 三個必填項: email, passwordrepeat
  • email 的形式需要符合電子郵件的標準
  • passwordrepeat 必須一致

當然除了這幾個規則,我們還希望在表單未驗證通過時提交按鈕是不可用的。

<form novalidate #f="ngForm">
  <label>
    <span>電子郵件地址</span>
    <input
      type="text"
      name="email"
      placeholder="請輸入您的 email 地址"
      [ngModel]="user.email"
      required
      pattern="([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+.[a-zA-Z]{2,4}">
  </label>
  <div>
    <label>
      <span>密碼</span>
      <input
        type="password"
        name="password"
        placeholder="請輸入您的密碼"
        [(ngModel)]="user.password"
        required
        minlength="8">
    </label>
    <label>
      <span>確認密碼</span>
      <input
        type="password"
        name="repeat"
        placeholder="請再次輸入密碼"
        [(ngModel)]="user.repeat"
        required
        minlength="8">
    </label>
  </div>
  <!-- 省略其他部分 -->
  <button type="submit" [disabled]="f.invalid">註冊</button>
</form>
<div>複製程式碼

Angular 中有幾種內建支援的驗證器( Validators )

  • required - 需要 FormControl 有非空值
  • minlength - 需要 FormControl 有最小長度的值
  • maxlength - 需要 FormControl 有最大長度的值
  • pattern - 需要 FormControl 的值可以匹配正規表示式

如果我們想看到結果的話,我們可以在模版中加上下面的程式碼,將錯誤以 JSON 形式輸出即可。

<div>
  <span>email 驗證:</span> {{f.controls.email?.errors | json}}
</div>複製程式碼

我們看到,如果不填電子郵件的話,錯誤的 JSON 是 {"required": true} ,這告訴我們目前有一個 required 的規則沒有被滿足。

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

當我們輸入一個字母 w 之後,就會發現錯誤變成了下面的樣子。這是因為我們對於 email 應用了多個規則,當必填項滿足後,系統會繼續檢查其他驗證結果。

{ 
"pattern": 
    { 
        "requiredPattern": "^([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|_|.]?)*[a-zA-Z0-9]+.[a-zA-Z]{2,4}$", 
        "actualValue": "w" 
    } 
}複製程式碼

通過幾次實驗,我們應該可以得出結論,當驗證未通過時,驗證器返回的是一個物件, key 為驗證的規則(比如 required, minlength 等),value 為驗證結果。如果驗證通過,返回的是一個 null

知道這一點後,我們其實就可以做出驗證出錯的提示了,為了方便引用,我們還是匯出 ngModel 到一個 email 引用,然後就可以訪問這個 FormControl 的各個屬性了:驗證的狀態( valid/invalid )、控制元件的狀態(是否獲得過焦點 -- touched/untouched,是否更改過內容 -- pristine/dirty 等)

<label>
  <span>電子郵件地址</span>
  <input
    ...
    [ngModel]="user.email"
    #email="ngModel">
</label>
<div *ngIf="email.errors?.required && email.touched" class="error">
  email 是必填項
</div>
<div *ngIf="email.errors?.pattern && email.touched" class="error">
  email 格式不正確
</div>複製程式碼

自定義驗證

內建的驗證器對於兩個密碼比較的這種驗證是不夠的,那麼這就需要我們自己定義一個驗證器。對於響應式表單來說,會比較簡單一些,但對於模版驅動的表單,這需要我們實現一個指令來使這個驗證器更通用和更一致。因為我們希望實現的樣子應該是和 requiredminlength 等差不多的形式,比如下面這個樣子 validateEqual="repeat"

<div>
    <label>
      <span>密碼</span>
      <input
        type="password"
        name="password"
        placeholder="請輸入您的密碼"
        [(ngModel)]="user.password"
        required
        minlength="8"
        validateEqual="repeat">
    </label>
    <label>
      <span>確認密碼</span>
      <input
        type="password"
        name="repeat"
        placeholder="請再次輸入密碼"
        [(ngModel)]="user.repeat"
        required
        minlength="8">
    </label>
  </div>複製程式碼

那麼要實現這種形式的驗證的話,我們需要建立一個指令,而且這個指令應該實現 Validator 介面。一個基礎的框架如下:

import { Directive, forwardRef } from '@angular/core';
import { NG_VALIDATORS, Validator, AbstractControl } from '@angular/forms';

@Directive({
  selector: '[validateEqual][ngModel]',
  providers: [
    { 
      provide: NG_VALIDATORS, 
      useExisting: forwardRef(()=>RepeatValidatorDirective), 
      multi: true 
    }
  ]
})
export class RepeatValidatorDirective implements Validator{
  constructor() { }
  validate(c: AbstractControl): { [key: string]: any } {
    return null;
  }
}複製程式碼

我們還沒有開始正式的寫驗證邏輯,但上面的框架已經出現了幾個有意思的點:

  1. Validator 介面要求必須實現的一個方法是 validate(c: AbstractControl): ValidationErrors | null; 。這個也就是我們前面提到的驗證正確返回 null 否則返回一個物件,雖然沒有嚴格的約束,但其 key 一般用於表示這個驗證器的名字或者驗證的規則名字,value 一般是失敗的原因或驗證結果。
  2. 和元件類似,指令也有 selector 這個後設資料,用於選擇那個元素應用該指令,那麼我們這裡除了要求 DOM 元素應用 validateEqual 之外,還需要它是一個 ngModel 元素,這樣它才是一個 FormControl,我們在 validate 的時候才是合法的。
  3. 那麼那個 providers 裡面那些面目可憎的傢伙又是幹什麼的呢? Angular 對於在一個 FormControl 上執行驗證器有一個內部機制: Angular 維護一個令牌為 NG_VALIDATORSmulti provider(簡單來說,Angular 為一個單一令牌注入多個值的這種形式叫 multi provider )。所有的內建驗證器都是加到這個 NG_VALIDATORS 的令牌上的,因此在做驗證時,Angular 是注入了 NG_VALIDATORS 的依賴,也就是所有的驗證器,然後一個個的按順序執行。因此我們這裡也把自己加到這個 NG_VALIDATORS 中去。
  4. 但如果我們直接寫成 useExisting: RepeatValidatorDirective 會出現一個問題, RepeatValidatorDirective 還沒有生成,你怎麼能在後設資料中使用呢?這就需要使用 forwardRef 來解決這個問題,它接受一個返回一個類的函式作為引數,但這個函式不會立即被呼叫,而是在該類宣告後被呼叫,也就避免了 undefined 的狀況。

下面我們就來實現這個驗證邏輯,由於密碼和確認密碼有主從關係,並非完全的平行關係。也就是說,密碼是一個基準對比物件,當密碼改變時,我們不應該提示密碼和確認密碼不符,而是應該將錯誤放在確認密碼中。所以我們給出另一個屬性 reverse


export class RepeatValidatorDirective implements Validator{
  constructor(
    @Attribute('validateEqual') public validateEqual: string,
    @Attribute('reverse') public reverse: string) { }

  private get isReverse() {
    if (!this.reverse) return false;
    return this.reverse === 'true' ? true: false;
  }

  validate(c: AbstractControl): { [key: string]: any } {
    // 控制元件自身值
    let self = c.value;

    // 要對比的值,也就是在 validateEqual=“ctrlname” 的那個控制元件的值
    let target = c.root.get(this.validateEqual);

    // 不反向查詢且值不相等
    if (target && self !== target.value && !this.isReverse) {
      return {
        validateEqual: true
      }
    }

    // 反向查詢且值相等
    if (target && self === target.value && this.isReverse) {
        delete target.errors['validateEqual'];
        if (!Object.keys(target.errors).length) target.setErrors(null);
    }

    // 反向查詢且值不相等
    if (target && self !== target.value && this.isReverse) {
        target.setErrors({
            validateEqual: true
        })
    }

    return null;
  }
}複製程式碼

這樣改造後,我們的模版檔案中對於密碼和確認密碼的驗證器如下:

<input
    type="password"
    name="password"
    placeholder="請輸入您的密碼"
    [(ngModel)]="user.password"
    #password="ngModel"
    required
    minlength="8"
    validateEqual="repeat"
    reverse="true">
<!-- 省略其他部分 -->
<input
    type="password"
    name="repeat"
    placeholder="請再次輸入密碼"
    [(ngModel)]="user.repeat"
    #repeat="ngModel"
    required
    minlength="8"
    validateEqual="password"
    reverse="false">複製程式碼

細說 Angular 2+ 的表單(一):模板驅動型表單
完成後的驗證錯誤提示

表單的提交

表單的提交比較簡單,繫結表單的 ngSubmit 事件即可

<form novalidate #f="ngForm" (ngSubmit)="onSubmit(f, $event)">複製程式碼

但需要注意的一點是,button如果不指定型別的話,會被當做 type="submit",所以當按鈕不是進行提交表單的話,需要顯式指定 type="button" 。而且如果遇到點選提交按鈕頁面重新整理的情況的話,意味著預設的表單提交事件引起了瀏覽器的重新整理,這種時候需要阻止事件冒泡。

onSubmit({value, valid}, event: Event){ 
  if(valid){
    console.log(value);
  }
  event.preventDefault();
}複製程式碼

對於模板驅動的表單,我們就先總結到這裡,下一篇文章我們會一起討論響應式表單。

本文程式碼:github.com/wpcfan/ng-f…

最後再提一下,本文評論區會抽出5人贈送我的 《Angular 從零到一》紙書,歡迎大家圍觀、訂購、提出寶貴意見。

下面是書籍的內容簡介:

本書系統介紹Angular的基礎知識與開發技巧,可幫助前端開發者快速入門。共有9章,第1章介紹Angular的基本概念,第2~7章從零開始搭建一個待辦事項應用,然後逐步增加功能,如增加登入驗證、將應用模組化、多使用者版本的實現、使用第三方樣式庫、動態效果製作等。第8章介紹響應式程式設計的概念和Rx在Angular中的應用。第9章介紹在React中非常流行的Redux狀態管理機制,這種機制的引入可以讓程式碼和邏輯隔離得更好,在團隊工作中強烈建議採用這種方案。本書不僅講解Angular的基本概念和最佳實踐,而且分享了作者解決問題的過程和邏輯,講解細膩,風趣幽默,適合有物件導向程式設計基礎的讀者閱讀。

京東連結:item.m.jd.com/product/120…

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

相關文章