Angular4 响应式表单应用以及验证

Yanglinxiao

基础的表单类

  • AbstractControl是三个具体表单类的抽象基类。 并为它们提供了一些共同的行为和属性,其中有些是可观察对象(Observable)。
  • FormControl 用于跟踪一个单独的表单控件的值和有效性状态。它对应于一个HTML表单控件,比如输入框和下拉框。
  • FormGroup用于 跟踪一组AbstractControl的实例的值和有效性状态。 该组的属性中包含了它的子控件。 组件中的顶级表单就是一个FormGroup。
  • FormArray用于跟踪AbstractControl实例组成的有序数组的值和有效性状态。

添加FormGroup

通常,如果有多个FormControl,我们会希望把它们注册进一个父FormGroup中:

src/app/hero-detail.component.ts

import { Component }              from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';    

export class HeroDetailComponent2 {
  heroForm = new FormGroup ({
    name: new FormControl()
  });
}

现在我们改完了这个类,该把它映射到模板中了,把hero-detail.component.html改成这样:

src/app/hero-detail.component.html

<h2>Hero Detail</h2>
<h3><i>FormControl in a FormGroup</i></h3>
<form [formGroup]="heroForm" novalidate>
  <div class="form-group">
    <label class="center-block">Name:
      <input class="form-control" formControlName="name">
    </label>
  </div>
</form>
  • 注意,现在单行输入框位于一个form元素中。<form>元素上的novalidate属性会阻止浏览器使用原生HTML中的表单验证器。
  • formGroup是一个响应式表单的指令,它拿到一个现有FormGroup实例,并把它关联到一个HTML元素上。 这种情况下,它关联到的是form元素上的FormGroup实例heroForm。
  • 没有父FormGroup的时候,[formControl]="name"也能正常工作,因为该指令可以独立工作,也就是说,不在FormGroup中时它也能用。有了FormGroup,name输入框就需要再添加一个语法formControlName=name,以便让它关联到类中正确的FormControl上。这个语法告诉Angular,查阅父FormGroup(这里是heroForm),然后在这个FormGroup中查阅一个名叫name的FormControl。

表单模型概览

要想知道表单模型是什么样的,请在hero-detail.component.html的form标签紧后面添加如下代码:

src/app/hero-detail.component.html
 content_copy
<p>Form value: {{ heroForm.value | json }}</p>
<p>Form status: {{ heroForm.status | json }}</p>

FormBuilder简介

现在,我们遵循下列步骤用FormBuilder来把HeroDetailComponent重构得更加容易读写:

  • 明确把heroForm属性的类型声明为FormGroup,稍后我们会初始化它。
  • 把FormBuilder注入到构造函数中。
  • 添加一个名叫createForm的新方法,它会用FormBuilder来定义heroForm。
  • 在构造函数中调用createForm。
src/app/hero-detail.component.ts

import { Component }              from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

export class HeroDetailComponent3 {
  heroForm: FormGroup; // <--- heroForm is of type FormGroup

  constructor(private fb: FormBuilder) { // <--- inject FormBuilder
    this.createForm();
  }

  createForm() {
    this.heroForm = this.fb.group({
      name: '', // <--- the FormControl called "name"
    });
  }
}

FormBuilder.group是一个用来创建FormGroup的工厂方法,它接受一个对象,对象的键和值分别是FormControl的名字和它的定义。 在这个例子中,name控件的初始值是空字符串。

Validators.required 验证器

要想让name这个FormControl是必须的,请把FormGroup中的name属性改为一个数组。第一个条目是name的初始值,第二个是required验证器:Validators.required。

src/app/hero-detail.component.ts 

this.heroForm = this.fb.group({
  name: ['', Validators.required ],
});

多级FormGroup

用FormBuilder在这个名叫heroForm的组件中创建一个FormGroup,并把它用作父FormGroup。 再次使用FormBuilder创建一个子级FormGroup,其中包括这些住址控件。把结果赋值给父FormGroup中新的address属性:

src/app/hero-detail.component.ts (excerpt)
 
export class HeroDetailComponent5 {
  heroForm: FormGroup;
  states = states;

  constructor(private fb: FormBuilder) {
    this.createForm();
  }

  createForm() {
    this.heroForm = this.fb.group({ // <-- the parent FormGroup
      name: ['', Validators.required ],
      address: this.fb.group({ // <-- the child FormGroup
        street: '',
        city: '',
        state: '',
        zip: ''
      }),
      power: '',
      sidekick: ''
    });
  }
}

在hero-detail.component.html中,把与住址有关的FormControl包裹进一个div中。 往这个div上添加一个formGroupName指令,并且把它绑定到"address"上。 这个address属性是一个FormGroup,它的父FormGroup就是heroForm:

src/app/hero-detail.component.html (address)

<div formGroupName="address" class="well well-lg">
  <h4>Secret Lair</h4>
  <div class="form-group">
    <label class="center-block">Street:
      <input class="form-control" formControlName="street">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">City:
      <input class="form-control" formControlName="city">
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">State:
      <select class="form-control" formControlName="state">
        <option *ngFor="let state of states" [value]="state">{{state}}</option>
      </select>
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Zip Code:
      <input class="form-control" formControlName="zip">
    </label>
  </div>
</div>

查看FormControl的属性

可以使用.get()方法来提取表单中一个单独FormControl的状态。 我们可以在组件类中这么做,或者通过往模板中添加下列代码来把它显示在页面中,就添加在{{form.value | json}}插值表达式的紧后面:

src/app/hero-detail.component.html

<p>Name value: {{ heroForm.get('name').value }}</p>
<p>Street value: {{ heroForm.get('address.street').value}}</p>

数据模型与表单模型

来自服务器的hero就是数据模型,而FormControl的结构就是表单模型。

组件必须把数据模型中的英雄值复制到表单模型中,这里隐含着两个非常重要的点:

  • 开发人员必须理解数据模型是如何映射到表单模型中的属性的
  • 户修改时的数据流是从DOM元素流向表单模型的,而不是数据模型,表单控件永远不会修改数据模型

通常只会展现数据模型的一个子集,表单模型的形态越接近数据模型,事情就会越简单,在HeroDetailComponent中,这两个模型是非常接近的,data-model.ts中的Hero定义:

export class Hero {
  id = 0;
  name = '';
  addresses: Address[];
}

export class Address {
  street = '';
  city   = '';
  state  = '';
  zip    = '';
}

组件的FormGroup定义:

src/app/hero-detail.component.ts 

this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group({
    street: '',
    city: '',
    state: '',
    zip: ''
  }),
  power: '',
  sidekick: ''
});

在这些模型中有两点显著的差异:

  • Hero有一个id。表单模型中则没有,因为我们通常不会把主键展示给用户
  • Hero有一个住址数组。这个表单模型只表示了一个住址
    重构一下address这个FormGroup定义,来让它更简洁清晰,代码如下:
src/app/hero-detail.component.ts 

this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group(new Address()), // <-- a FormGroup with a new address
  power: '',
  sidekick: ''
});

使用setValue和patchValue来操纵表单模型

setValue 方法

借助setValue,我们可以立即设置每个表单控件的值,只要把与表单模型的属性精确匹配的数据模型传进去就可以了

src/app/hero-detail.component.ts 

this.heroForm.setValue({
  name:    this.hero.name,
  address: this.hero.addresses[0] || new Address()
});

setValue方法会在赋值给任何表单控件之前先检查数据对象的值。

它不会接受一个与FormGroup结构不同或缺少表单组中任何一个控件的数据对象。 这种方式下,如果我们有什么拼写错误或控件嵌套的不正确,它就能返回一些有用的错误信息。 patchValue会默默地失败。

而setValue会捕获错误,并清晰的报告它。

注意,你几乎可以把这个hero用作setValue的参数,因为它的形态与组件的FormGroup结构是非常像的。

我们现在只能显示英雄的第一个住址,不过我们还必须考虑hero完全没有住址的可能性。 下面的例子解释了如何在数据对象参数中对address属性进行有条件的设置:

address: this.hero.addresses[0] || new Address()

patchValue 方法

借助patchValue,我们可以通过提供一个只包含要更新的控件的键值对象来把值赋给FormGroup中的指定控件,这个例子只会设置表单的name控件:

this.heroForm.patchValue({
  name: this.hero.name
});

借助patchValue,我们可以更灵活地解决数据模型和表单模型之间的差异。 但是和setValue不同,patchValue不会检查缺失的控件值,并且不会抛出有用的错误信息。

什么时候设置表单的模型值(ngOnChanges)

什么时候设置表单的模型值取决于组件何时得到数据模型的值

HeroListComponent组件把英雄的名字显示给用户,当用户点击一个英雄时,列表组件把所选的英雄通过输入属性hero传给HeroDetailComponent:

hero-list.component.html (simplified)

<nav>
  <a *ngFor="let hero of heroes | async" (click)="select(hero)">{{hero.name}}</a>
</nav>

<div *ngIf="selectedHero">
  <app-hero-detail [hero]="selectedHero"></app-hero-detail>
</div>

这种方式下,每当用户选择一个新英雄时,HeroDetailComponent中的hero值就会发生变化。 我们可以在ngOnChanges钩子中调用setValue,就像例子中所演示的那样, 每当输入属性hero发生变化时,Angular就会调用它。

src/app/hero-detail.component.ts (ngOnchanges)

ngOnChanges()
  this.heroForm.setValue({
    name:    this.hero.name,
    address: this.hero.addresses[0] || new Address()
  });
}

重置表单的标识

我们应该在更换英雄的时候重置表单,以便来自前一个英雄的控件值被清除,并且其状态被恢复为pristine(原始)状态。 我们可以在ngOnChanges的顶部调用reset,就像这样:

src/app/hero-detail-7.component.ts

this.heroForm.reset();

reset方法有一个可选的state值,让我们能在重置状态的同时顺便设置控件的值。 在内部实现上,reset会把该参数传给了setValue。 略微重构之后,ngOnChanges会变成这样:

src/app/hero-detail.component.ts (ngOnchanges - revised)

ngOnChanges() {
  this.heroForm.reset({
    name: this.hero.name,
    address: this.hero.addresses[0] || new Address()
  });
}

使用FormArray来表示FormGroup数组

src/app/hero-detail.component.ts
 
this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  secretLairs: this.fb.array([]), // <-- secretLairs as an empty FormArray
  power: '',
  sidekick: ''
});

把表单的控件名从address改为secretLairs让我们遇到了一个重要问题:表单模型与数据模型不再匹配了。
### 初始化FormArray型的secretLairs
下面的setAddresses方法把secretLairs数组替换为一个新的FormArray,使用一组表示英雄地址的FormGroup来进行初始化:

src/app/hero-detail.component.ts

setAddresses(addresses: Address[]) {
  const addressFGs = addresses.map(address => this.fb.group(address));
  const addressFormArray = this.fb.array(addressFGs);
  this.heroForm.setControl('secretLairs', addressFormArray);
}

注意,我们使用FormGroup.setControl方法,而不是setValue方法来设置前一个FormArray。 我们所要替换的是控件,而不是控件的值。

### 获取FormArray
使用FormGroup.get方法来获取到FormArray的引用:

get secretLairs(): FormArray {
  return this.heroForm.get('secretLairs') as FormArray;
};

### 显示FormArray
诀窍在于要知道如何编写*ngFor。主要有三点:

  • 在*ngFor的<div>之外套上另一个包装<div>,并且把它的formArrayName指令设为"secretLairs"。 这一步为内部的表单控件建立了一个FormArray型的secretLairs作为上下文,以便重复渲染HTML模板。
  • 这些重复条目的数据源是FormArray.controls而不是FormArray本身。 每个控件都是一个FormGroup型的地址对象,与以前的模板HTML所期望的格式完全一样。
  • 每个被重复渲染的FormGroup都需要一个独一无二的formGroupName,它必须是FormGroup在这个FormArray中的索引。 我们将复用这个索引,以便为每个地址组合出一个独一无二的标签。
src/app/hero-detail.component.html (excerpt)
 
<div formArrayName="secretLairs" class="well well-lg">
  <div *ngFor="let address of secretLairs.controls; let i=index" [formGroupName]="i" >
    <!-- The repeated address template -->
    <h4>Address #{{i + 1}}</h4>
    <div style="margin-left: 1em;">
      <div class="form-group">
        <label class="center-block">Street:
          <input class="form-control" formControlName="street">
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">City:
          <input class="form-control" formControlName="city">
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">State:
          <select class="form-control" formControlName="state">
            <option *ngFor="let state of states" [value]="state">{{state}}</option>
          </select>
        </label>
      </div>
      <div class="form-group">
        <label class="center-block">Zip Code:
          <input class="form-control" formControlName="zip">
        </label>
      </div>
    </div>
    <br>
    <!-- End of the repeated address template -->
  </div>
</div>

### 把新的address添加到FormArray中

addLair() {
  this.heroForm.get('secretLairs').push(this.fb.group(new Address()));
}

监视控件的变化

每当用户在父组件HeroListComponent中选取了一个英雄,Angular就会调用一次ngOnChanges。 选取英雄会修改输入属性HeroDetailComponent.hero。

当用户修改英雄的名字或秘密小屋时,Angular并不会调用ngOnChanges。 幸运的是,我们可以通过订阅表单控件的属性之一来了解这些变化,此属性会发出变更通知。

有一些属性,比如valueChanges,可以返回一个RxJS的Observable对象。 要监听控件值的变化,我们并不需要对RxJS的Observable了解更多。

添加下列方法,以监听姓名这个FormControl中值的变化:

src/app/hero-detail.component.ts (logNameChange)

nameChangeLog: string[] = [];
logNameChange() {
  const nameControl = this.heroForm.get('name');
  nameControl.valueChanges.forEach(
    (value: string) => this.nameChangeLog.push(value)
  );
}

在构造函数中调用它,就在创建表单的代码之后:

src/app/hero-detail-8.component.ts
 
constructor(private fb: FormBuilder) {
  this.createForm();
  this.logNameChange();
}

保存表单数据

### 保存
当用户提交表单时,HeroDetailComponent会把英雄实例的数据模型传给所注入进来的HeroService的一个方法来进行保存:

src/app/hero-detail.component.ts (onSubmit)
 
onSubmit() {
  this.hero = this.prepareSaveHero();
  this.heroService.updateHero(this.hero).subscribe(/* error handling */);
  this.ngOnChanges();
}

原始的hero中有一些保存之前的值,用户的修改仍然是在表单模型中。 所以我们要根据原始英雄(根据hero.id找到它)的值组合出一个新的hero对象,并用prepareSaveHero助手来深层复制变化后的模型值。

src/app/hero-detail.component.ts (prepareSaveHero)

prepareSaveHero(): Hero {
  const formModel = this.heroForm.value;

  // deep copy of form model lairs
  const secretLairsDeepCopy: Address[] = formModel.secretLairs.map(
    (address: Address) => Object.assign({}, address)
  );

  // return new `Hero` object containing a combination of original hero value(s)
  // and deep copies of changed form model values
  const saveHero: Hero = {
    id: this.hero.id,
    name: formModel.name as string,
    // addresses: formModel.secretLairs // <-- bad!
    addresses: secretLairsDeepCopy
  };
  return saveHero;
}

地址的深层复制

我们已经把formModel.secretLairs赋值给了saveHero.addresses(参见注释掉的部分), saveHero.addresses数组中的地址和formModel.secretLairs中的会是同一个对象。 用户随后对小屋所在街道的修改将会改变saveHero中的街道地址。

但prepareSaveHero方法会制作表单模型中的secretLairs对象的复本,因此实际上并没有修改原有对象。

### 丢弃(撤销修改)
丢弃很容易。只要重新执行ngOnChanges方法就可以拆而,它会重新从原始的、未修改过的hero数据模型来构建出表单模型:

src/app/hero-detail.component.ts (revert)

revert() { this.ngOnChanges(); }

响应式表单的验证

在响应式表单中,真正的源码都在组件类中。我们不应该通过模板上的属性来添加验证器,而应该在组件类中直接把验证器函数添加到表单控件模型上(FormControl)。然后,一旦控件发生了变化,Angular 就会调用这些函数。

验证器函数

有两种验证器函数:同步验证器和异步验证器。

  • 同步验证器函数接受一个控件实例,然后返回一组验证错误或null。我们可以在实例化一个FormControl时把它作为构造函数的第二个参数传进去。
  • 异步验证器函数接受一个控件实例,并返回一个承诺(Promise)或可观察对象(Observable),它们稍后会发出一组验证错误或者null。我们可以在实例化一个FormControl时把它作为构造函数的第三个参数传进去。

注意:出于性能方面的考虑,只有在所有同步验证器都通过之后,Angular 才会运行异步验证器。当每一个异步验证器都执行完之后,才会设置这些验证错误。

内置验证器

模板驱动表单中可用的那些属性型验证器(如required、minlength等)对应于Validators类中的同名函数。要想查看内置验证器的全列表,参见 API 参考手册中的Validators部分。

reactive/hero-form-reactive.component.ts (validator functions)

ngOnInit(): void {
  this.heroForm = new FormGroup({
    'name': new FormControl(this.hero.name, [
      Validators.required,
      Validators.minLength(4),
      forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
    ]),
    'alterEgo': new FormControl(this.hero.alterEgo),
    'power': new FormControl(this.hero.power, Validators.required)
  });
}

get name() { return this.heroForm.get('name'); }

get power() { return this.heroForm.get('power'); }

注意

  • name控件设置了两个内置验证器:Validators.required 和 Validators.minLength(4)。
  • 由于这些验证器都是同步验证器,因此我们要把它们作为第二个参数传进去。
  • 可以通过把这些函数放进一个数组后传进去,可以支持多重验证器。
  • 这个例子添加了一些getter方法。在响应式表单中,我们通常会通过它所属的控件组(FormGroup)的get方法来访问表单控件,但有时候为模板定义一些getter作为简短形式。
reactive/hero-form-reactive.component.html (name with error msg)

<input id="name" class="form-control"
       formControlName="name" required >

<div *ngIf="name.invalid && (name.dirty || name.touched)"
     class="alert alert-danger">

  <div *ngIf="name.errors.required">
    Name is required.
  </div>
  <div *ngIf="name.errors.minlength">
    Name must be at least 4 characters long.
  </div>
  <div *ngIf="name.errors.forbiddenName">
    Name cannot be Bob.
  </div>
</div>

自定义验证器

shared/forbidden-name.directive.ts (forbiddenNameValidator)

/** A hero's name can't match the given regular expression */
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
  return (control: AbstractControl): {[key: string]: any} => {
    const forbidden = nameRe.test(control.value);
    return forbidden ? {'forbiddenName': {value: control.value}} : null;
  };
}

这个函数实际上是一个工厂,它接受一个用来检测指定名字是否已被禁用的正则表达式,并返回一个验证器函数。

在本例中,禁止的名字是“bob”; 验证器会拒绝任何带有“bob”的英雄名字。 在其他地方,只要配置的正则表达式可以匹配上,它可能拒绝“alice”或者任何其他名字。

forbiddenNameValidator工厂函数返回配置好的验证器函数。 该函数接受一个Angular控制器对象,并在控制器值有效时返回null,或无效时返回验证错误对象。 验证错误对象通常有一个名为验证秘钥(forbiddenName)的属性。其值为一个任意词典,我们可以用来插入错误信息

添加响应式表单

在响应式表单组件中,添加自定义验证器相当简单。你所要做的一切就是直接把这个函数传给 FormControl 。

reactive/hero-form-reactive.component.ts (validator functions)

this.heroForm = new FormGroup({
  'name': new FormControl(this.hero.name, [
    Validators.required,
    Validators.minLength(4),
    forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
  ]),
  'alterEgo': new FormControl(this.hero.alterEgo),
  'power': new FormControl(this.hero.power, Validators.required)
});
阅读 14.6k

learn more === know less

1.2k 声望
26 粉丝
0 条评论

learn more === know less

1.2k 声望
26 粉丝
文章目录
宣传栏