细说 Angular 2+ 的表单(二):响应式表单

96
接灰的电子产品
2017.06.13 16:18* 字数 2596

细说 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 到浏览器中看一下结果了:

响应式表单对于多值验证的处理
响应式表单对于多值验证的处理

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: []
    })
  }

到这里我们的结构就建好了,保存后,到浏览器中去试试添加多个地址吧!

FormArray 处理结构相同的多组表单项
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 卓越的处理能力。

慕课网 Angular 视频课上线: http://coding.imooc.com/class/123.html?mc_marking=1fdb7649e8a8143e8b81e221f9621c4a&mc_channel=banner

前端随笔
Web note ad 1