摘要
在企業應用開發時,表單是一個躲不過去的事情,和面向消費者的應用不同,企業領域的開發中,表單的使用量是驚人的。這些表單的處理其實是一個挺復雜的事情,比如有的是涉及到多個 Tab 的表單,有的是向導形式多個步驟的,各種復雜的驗證邏輯和時不時需要彈出的對話框等等。筆者試圖在這一系列文章中對 Angular 中的表單處理做一個相對完整的梳理。
Angular 中提供兩種類型的表單處理機制,一種叫模版驅動型(Template Driven)的表單,另一種叫模型驅動型表單( Model Driven ),這后一種也叫響應式表單 ( Reactive Forms ),由于模版驅動中有一個 ngModel
的指令,容易和這里說的模型驅動混淆,所以在我們的文章中叫后一種說法:響應式表單。
第一篇主要介紹模版驅動型的表單。
模版驅動的表單
模版驅動的表單和 AngularJS 對于表單的處理類似,把一些指令(比如 ngModel
)、數據值和行為約束(比如 require
、minlength
等等)綁定到模版中(模版就是組件元數據 @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>
渲染之后的效果就像下面這樣:

數據綁定
對于模版驅動型的表單處理,我們首先需要在對應的模塊中引入 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 形式出現的表單值:

單向數據綁定
那么接下來,我們看看 [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
,password
和repeat
-
email
的形式需要符合電子郵件的標準 -
password
和repeat
必須一致
當然除了這幾個規則,我們還希望在表單未驗證通過時提交按鈕是不可用的。
<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>
自定義驗證
內建的驗證器對于兩個密碼比較的這種驗證是不夠的,那么這就需要我們自己定義一個驗證器。對于響應式表單來說,會比較簡單一些,但對于模版驅動的表單,這需要我們實現一個指令來使這個驗證器更通用和更一致。因為我們希望實現的樣子應該是和 required
、minlength
等差不多的形式,比如下面這個樣子 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;
}
}
我們還沒有開始正式的寫驗證邏輯,但上面的框架已經出現了幾個有意思的點:
- Validator 接口要求必須實現的一個方法是
validate(c: AbstractControl): ValidationErrors | null;
。這個也就是我們前面提到的驗證正確返回 null 否則返回一個對象,雖然沒有嚴格的約束,但其 key 一般用于表示這個驗證器的名字或者驗證的規則名字,value 一般是失敗的原因或驗證結果。 - 和組件類似,指令也有
selector
這個元數據,用于選擇那個元素應用該指令,那么我們這里除了要求 DOM 元素應用validateEqual
之外,還需要它是一個ngModel
元素,這樣它才是一個 FormControl,我們在 validate 的時候才是合法的。 - 那么那個 providers 里面那些面目可憎的家伙又是干什么的呢? Angular 對于在一個 FormControl 上執行驗證器有一個內部機制: Angular 維護一個令牌為
NG_VALIDATORS
的multi provider
(簡單來說,Angular 為一個單一令牌注入多個值的這種形式叫multi provider
)。所有的內建驗證器都是加到這個NG_VALIDATORS
的令牌上的,因此在做驗證時,Angular 是注入了NG_VALIDATORS
的依賴,也就是所有的驗證器,然后一個個的按順序執行。因此我們這里也把自己加到這個NG_VALIDATORS
中去。 - 但如果我們直接寫成
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();
}
對于模板驅動的表單,我們就先總結到這里,下一篇文章我們會一起討論響應式表單。
本文代碼:https://github.com/wpcfan/ng-features.git
慕課網 Angular 視頻課上線: http://coding.imooc.com/class/123.html?mc_marking=1fdb7649e8a8143e8b81e221f9621c4a&mc_channel=banner
有問題的童鞋可以加入我的小密圈討論: http://t.xiaomiquan.com/jayRnaQ (該鏈接7天內(5月14日前)有效)
最后再提一下,我的 《Angular 從零到一》紙書出版了,歡迎大家圍觀、訂購、提出寶貴意見。
下面是書籍的內容簡介:
本書系統介紹Angular的基礎知識與開發技巧,可幫助前端開發者快速入門。共有9章,第1章介紹Angular的基本概念,第2~7章從零開始搭建一個待辦事項應用,然后逐步增加功能,如增加登錄驗證、將應用模塊化、多用戶版本的實現、使用第三方樣式庫、動態效果制作等。第8章介紹響應式編程的概念和Rx在Angular中的應用。第9章介紹在React中非常流行的Redux狀態管理機制,這種機制的引入可以讓代碼和邏輯隔離得更好,在團隊工作中強烈建議采用這種方案。本書不僅講解Angular的基本概念和最佳實踐,而且分享了作者解決問題的過程和邏輯,講解細膩,風趣幽默,適合有面向對象編程基礎的讀者閱讀。
慕課網 Angular 視頻課上線: http://coding.imooc.com/class/123.html?mc_marking=1fdb7649e8a8143e8b81e221f9621c4a&mc_channel=banner
京東鏈接:https://item.m.jd.com/product/12059091.html?from=singlemessage&isappinstalled=0