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

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

摘要

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

Angular 中提供兩種類型的表單處理機制,一種叫模版驅動型(Template Driven)的表單,另外一種叫模型驅動型表單( Model Driven ),這後一種也叫響應式表單 ( Reactive Forms ),因爲模版驅動中有一個 ngModel 的指令,容易和這裏說的模型驅動混淆,因此在咱們的文章中叫後一種說法:響應式表單。前端

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

號外

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

模版驅動的表單

模版驅動的表單和 AngularJS 對於表單的處理相似,把一些指令(好比 ngModel )、數據值和行爲約束(好比 requireminlength 等等)綁定到模版中(模版就是組件元數據 @Component 中定義的那個 template ),這也是模版驅動這個叫法的來源。整體來講,這種類型的表單經過綁定把不少工做交給了模版。github

模版驅動的例子

仍是用例子來講話,好比咱們有一個用戶註冊的表單,用戶名就是 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 模版,先不增長任何的綁定或事件處理:typescript

<!-- 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>複製代碼

渲染以後的效果就像下面這樣:編程

簡單的Form

數據綁定

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

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 形式出現的表單值:

控件的輸入值同步到了表單的值中

單向數據綁定

那麼接下來,咱們看看 [ngModel] 有什麼用?若是咱們想給控件設置一個初始值怎麼辦呢,這時就須要進行一個單向綁定,方向是從組件到視圖。咱們能夠作的是在初始化 User 的時候,將 email 屬性設置成 wang@163.com

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

並且在模版中使用 [ngModel]="user.email" 進行單向綁定,這個語法其實和普通的屬性綁定是同樣的,用中括號標示這是一個要進行數據綁定的屬性,等號右邊是須要綁定的值(這裏是 user.email )。那麼咱們就能夠獲得下面這樣的輸出了, email 的初始值被綁定成功!

單向數據綁定

雙向數據綁定

但上面的例子存在一個問題,數據的綁定是單向的,也就是說,在輸入框進行輸入的時候,咱們的 user 的值不會隨之改變的。爲了更好的說明,咱們將 user 和 表單的值同時輸出

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

此時咱們將默認的電子郵件改爲 wang@gmail.com 的話,表單的值是改變了,但 user 並未改變。

輸入的值影響了表單,但不會影響領域對象

若是咱們但願的是在輸入時,這個輸入的值也反向的影響咱們的 user 對象的值的話,那就須要用到雙向綁定了,也就是 [(ngModel)] 須要上場了。

表單和領域對象的值保持了同步

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

<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>複製代碼

這樣的話,咱們再來看一下輸出,如今就徹底一致了:

表單和領域對象的結構也徹底一致了

數據驗證

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

  • 三個必填項: 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 的規則沒有被知足。

驗證結果

當咱們輸入一個字母 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">複製代碼

完成後的驗證錯誤提示

表單的提交

表單的提交比較簡單,綁定表單的 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從零到一
相關文章
相關標籤/搜索