细说 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
- 但同时每个控件也去掉了验证条件,比如
required
、minlength
等 - 在地址分组中用
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
。比如下面的 key
是 password
,对应的值是 new FormControl('', [Validators.required])
。这个 key
对应的就是模板中的 formControlName
的值,我们模板代码中设置了 formControlName="password"
,而表单控件会根据这个 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
, group
和 array
,分别对应 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
到浏览器中看一下结果了:
FormArray 有什么用?
我们在购物网站经常遇到需要维护多个地址,因为我们有些商品希望送到公司,有些需要送到家里,还有些给父母采购的需要送到父母那里。这就是一个典型的 FormArray 可以派上用场的场景。所有的这些地址的结构都是一样的,有省、市、区县和街道地址,那么对于处理这样的场景,我们来看看在响应式表单中怎么做。
首先,我们需要把 HTML 模板改造一下,现在的地址是多项了,所以我们需要在原来的地址部分外面再套一层,并且声明成 formArrayName="addrs"
。 FormArray 顾名思义是一个数组,所以我们要对这个控件数组做一个循环,然后让每个数组元素是 FormGroup,只不过这次我们的 [formGroupName]="i"
是让 formGroupName
等于该数组元素的索引。
<div formArrayName="addrs">
<button (click)="addAddr()">Addbutton>
<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: []
})
}复制代码
到这里我们的结构就建好了,保存后,到浏览器中去试试添加多个地址吧!
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 卓越的处理能力。