Angular docs-表单-Reactive 表单

| 分类 programming  | 标签 angular  node  Angular docs 

Reactive forms 是用 reactive 风格创建表单的一种技术。

Reactive forms 和 template-driven forms 是 Angular 中创建表单的 2 种技术。它们都位于 @angular/forms 库,共享一组通用的表单控件类。它们在理念、编程风格上有区别。它们有不同的模块名: ReactiveFormsModuleFormsModule

Reactive 表单

Reactive 编码风格偏好显式管理数据流:即管理非 UI 的数据模型(data model, 通常从服务端获取)和 UI 的表单模型 (form model) 间的数据流,而表单模型可用来保存屏幕上 HTML 控件的值和状态。同时 reactive forms 提供了使用 reactive 模式、测试和验证的使用方法。

使用 reactive forms 时,我们在组件中先创建 Angular 表单控件对象树,再在模板中将它们绑定到表单元素上。

直接在组件类中创建和处理表单控件对象。由于组件类可直接访问数据模型和表单控件结构,我们可以将数据模板的值推送到表单控件中,也能抽回用户修改的值。组件能观测到表单控件的状态并作出响应。

直接操作表单控件对象的一个好处是:值和有效性的更新 总是同步的并且在你的控制下,从而不会碰到模板驱动的表单中的时间点的问题,因此,reactive forms 的单元测试也更加容易。

按照 reactive 范式,组件会保持数据模型的不变性,只将它作为一个纯粹的数值源。组件不直接更新数据模型,它先将用户的修改抽取出来,转发给外部组件或服务,外部组件或服务进行处理后(例如进行保存)将返回一个新的数据模型,组件将返回的新数据模型用来更新数据模型。

模板驱动的表单

将 HTML 表单控制(如 <input>) 放在组件模板中,使用 ngModel 将它们绑定到组件的数据模型的属性上。

我们不直接创建 Angular 表单控件对象,Angular 基于数据绑定信息为我们自动创建。我们不抽取的推送数据值,Angular 通过 ngModel 进行处理。当有修改时,Angular 更新那个可修改的数据模型。

因此,ngModle 指令不存在于 ReactiveFormsModule

异步 vs. 同步

reactive 表单是同步的,模板驱动的表单是异步的。

在 reactive 表单中,我们直接创建整个表单控件对象树,因此可以更新所有控件数据。

而模板驱动的表单中,表单控件的创建是由指令进行的。为避免 “changed after checked” 错误,这些指令都要花费多一个周期来创建整个控件树,即必须等待一个 tick 后才能在组件类中处理控件。

例如,如果用 @ViewChild(NgForm) 语句来获取注入的表单控件,并在 ngAfterViewInit 挂钩中检查,我们会发现组件没有该子对象。必须等待一个 tick( 使用 setTimeout) 后,才能访问该控件。

模板驱动表单的异步性也使单元测试很复杂。必须将测试块包装在 async()fakeAsync() 中来避免表单还不可用的问题。

下面以例子说明

创建数据模型

//src/app/data-model.ts
export class Hero {
  id = 0;
  name = '';
  addresses: Address[];
}

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

export const heroes: Hero[] = [
  {
    id: 1,
    name: 'Whirlwind',
    addresses: [
      {street: '123 Main',  city: 'Anywhere', state: 'CA',  zip: '94801'},
      {street: '456 Maple', city: 'Somewhere', state: 'VA', zip: '23226'},
    ]
  },
  {
    id: 2,
    name: 'Bombastic',
    addresses: [
      {street: '789 Elm',  city: 'Smallville', state: 'OH',  zip: '04501'},
    ]
  },
  {
    id: 3,
    name: 'Magneta',
    addresses: [ ]
  },
];

export const states = ['CA', 'MD', 'OH', 'VA'];

创建 reactive forms 的组件

//src/app/hero-detail.component.ts
import { Component }              from '@angular/core';
import { FormControl }            from '@angular/forms';

@Component({
  selector: 'hero-detail',
  templateUrl: './hero-detail.component.html'
})

export class HeroDetailComponent1 {
  name = new FormControl();
}

FormControl 是一个指令,可用来创建和管理 FormControl 实例,实例将绑定到模板中的 <input> 上。其构造器接收 3 个可选参数:初始值,一组验证器,另一个异步验证器。

创建模板

<!--src/app/hero-detail.component.html-->
<h2>Hero Detail</h2>
<h3><i>Just a FormControl</i></h3>
<label class="center-block">Name:
  <input class="form-control" [formControl]="name">
</label>

使用 [formControl]="name" 将 input 元素绑定到组件中的一个名为 name 的 FormControl 实例属性。

导入 ReactiveFormsModule

//src/app/app.module.ts (excerpt)
import { NgModule }            from '@angular/core';
import { BrowserModule }       from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';  // <-- #1 import module

import { AppComponent }        from './app.component';
import { HeroDetailComponent } from './hero-detail.component'; // <-- #1 import component

@NgModule({
  imports: [
    BrowserModule,
    ReactiveFormsModule // <-- #2 add to @NgModule imports
  ],
  declarations: [
    AppComponent,
    HeroDetailComponent, // <-- #3 declare app component
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

核心的表单类

  • AbstractControl 是 3 个具体表单控制类 FormControl, FormGroup, FormArray 的抽象基类。它提供它们的通用行为和属性,有些是可 observalbe 的。
  • 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()
  });
}

同步更新模板中的绑定方式:

<!--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> 元素中的 novalidate 属性避免浏览器使用本地的 HTML 验证。
  • formGroup 是一个 reactive forms 中的指令,它将当前表单关联到一个 FormGoup 实例。
  • <form> 中的 <input> 使用 fromControlName="name" 绑定,它将绑定到父 FormGroup 实例中的一个名为 name 的控件实例。

查看表单的数据模型

<!--src/app/hero-detail.component.html-->
<p>Form value: </p>
<p>Form status: </p>

heroForm.value 返回表单的数据模型。

使用 FormBuilder 来创建和维护复杂的表单

//src/app/hero-detail.component.ts (excerpt)
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"
    });
  }
}
  • 先声明 heroForm 属性为 FormGroup
  • 将 FormBuilder 注入到构造器
  • FormBuilder.group 是一个能创建 FormGroup 实例的工厂函数,接收的参数是一个键值对对象,用来定义 FormControl 的名字和定义。本例中, name 控件的初始值被设置为 ‘’

使用 Validator.required 验证器

//src/app/hero-detail.component.ts (excerpt)
import { Component }                          from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

//...
this.heroForm = this.fb.group({
  name: ['', Validators.required ],
});
  • 这里 name 控件的定义体是一个数组,有 2 元素分别是:初始值,验证器
  • Reactive 验证器都是简单的,可组合的函数。使用 Validators.required 后,heroForm.status 的值可为 "INVALID""VALID"

更多 FormControl

添加 address 等,address 还有 state 属性,需要使用 <select> 来实现选择:

//src/app/hero-detail.component.ts (excerpt)
import { Component }                          from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

import { states } from './data-model';

//...
export class HeroDetailComponent4 {
  heroForm: FormGroup;
  states = states;

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

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

在模板中绑定:

<!--src/app/hero-detail.component.html-->
<h2>Hero Detail</h2>
<h3><i>A FormGroup with multiple FormControls</i></h3>
<form [formGroup]="heroForm" novalidate>
  <div class="form-group">
    <label class="center-block">Name:
      <input class="form-control" formControlName="name">
    </label>
  </div>
  <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"></option>
      </select>
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Zip Code:
      <input class="form-control" formControlName="zip">
    </label>
  </div>
  <div class="form-group radio">
    <h4>Super power:</h4>
    <label class="center-block"><input type="radio" formControlName="power" value="flight">Flight</label>
    <label class="center-block"><input type="radio" formControlName="power" value="x-ray vision">X-ray vision</label>
    <label class="center-block"><input type="radio" formControlName="power" value="strength">Strength</label>
  </div>
  <div class="checkbox">
    <label class="center-block">
      <input type="checkbox" formControlName="sidekick">I have a sidekick.
    </label>
  </div>
</form>


<p>Form value: </p>

嵌套的 FormGroup

可将 street, city 等地址相关的控件组合成一个名为 address的 FormGroup,这样可以对这组控件的状态和有效性进行集中跟踪。

之前用 FormBuilder.group 为表单创建了一个 FormGroup,现可再用 group 创建嵌套的 FormGroup

//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: ''
    });
  }
}

在模板中绑定时,将创建一个 <div> 来包围所有地址控件,其 formGroupName 绑定到 address(从而创建了一个类型 FormGroup 的父对象),再在 <div> 内部声明各控件,并通过 formControlName 进行绑定(相对于其直接父对象):

//src/app/hero-detail.component.html (excerpt)
<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"></option>
      </select>
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Zip Code:
      <input class="form-control" formControlName="zip">
    </label>
  </div>
</div>

FormControl 属性研究

FormGroup 对象中,可直接用 get() 来获取其包含的 FormControl 实例,例如:

<p>Name value: </p>
<p>Street value: </p>

FormControl 有如下属性:

属性名 描述
obj.value 控件的值
obj.status 控件的状态值,有 VALID, INVALID, PENDING, DISABLED
obj.pristine 当用户没有进行修改时为真,与 obj.dirty 相反
obj.untouched 当用户没有使用过该控件时为真,与 obj.touched 相反

数据模型和表单模型

数据模型(本例是 hero)一般从服务端获取。 FormControl 树就是表单模型。

组件必须将数据模型中的 hero 值复制到表单模型中。需注意 2 点:

  1. 必须理解数据模型中的属性是如何映射到表单模型中的属性上的
  2. 用户的修改从 DOM 元素流向表单模型,而不是到数据模型。表单控件不可能会修改数据模型。

表单和数据模型结果无需完全匹配,我们一般只在特定页面上显示数据模型中的一个子块。

本例中,数据模型和表单模型非常相似,如下:

//src/app/data-model.ts (classes)
export class Hero {
  id = 0;
  name = '';
  addresses: Address[];
}

export class Address {
  street = '';
  city   = '';
  state  = '';
  zip    = '';
}
//src/app/hero-detail.component.ts (excerpt)
this.heroForm = this.fb.group({
  name: ['', Validators.required ],
  address: this.fb.group({
    street: '',
    city: '',
    state: '',
    zip: ''
  }),
  power: '',
  sidekick: ''
});

为简洁起见,重构表单模型为:

//src/app/hero-detail-7.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

参数是一个键值对,键值对中的结构(个数、名字)必须与表单模型完全匹配,不然会出错并返回错误消息,例如:

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

patchValue

只需传入感兴趣的控件设置信息,如:

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

何时设置表单模型的值 (ngOnChanges)

当组件获取到数据模型值时即要更新表单模型的值。

本例中 HeroDetailComponent 嵌套在一个 master/detail 的 HeroListComponent 中。当用户点击列表中的一个 hero 时,会将选中的 hero 通过属性绑定传给 HeroDetailComponent

<!--hero-list.component.html (simplified)-->
<nav>
  <a *ngFor="let hero of heroes | async" (click)="select(hero)"></a>
</nav>

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

每当用户选中一个新 hero 时 HeroDetailComponent 中的 hero 即有修改,会触发 ngOnChanges 挂钩调用,因此可在组件的 ngOnChanges 挂钩中使用 setValue 来更新表单模型值:

//src/app/hero-detail.component.ts (core imports)
import { Component, Input, OnChanges }             from '@angular/core';

//src/app/hero-detail-6.component.ts
@Input() hero: Hero;

//src/app/hero-detail.component.ts (ngOnchanges)
ngOnChanges()
  this.heroForm.setValue({
    name:    this.hero.name,
    address: this.hero.addresses[0] || new Address()
  });
}

重置表单状态

选中的 hero 修改后应重置表单,将控件值清空,状态重置为 pristine,可在 ngOnChanges 中首行调用 this.heroForm.reset() 实现。

reset()setValue() 也可以合并为如下:

//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

FormGroup 可以嵌套 FormControlFormGroup,而 FormArray 能表示一组 FormGroup

本例中 address 是一个 FormGroup,而 hero 有多个地址,可用 FormArray 表示(重命名为 secretLaires):

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

初始化 FormArray “secretLairs”

//src/app/hero-detail-8.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() 方法用来替换原来的 FormArray 实例。

获取 FormArray

//src/app/hero-detail.component.ts (secretLayers property)
get secretLairs(): FormArray {
  return this.heroForm.get('secretLairs') as FormArray;
};

显示 FormArray

使用 *ngFor 来迭代显示,有如下关键点:

  • 添加一层 <div>,应用 *ngFor,设置 formArrayName 指令为 "secretLairs",从而将该 FormArray 实例设置为内部模板中的表单控件的上下文。
  • 迭代的是 FormArray.controls,而不是 FormArray 实例本身,每个 control 就是一个 address FormGroup
  • 每个迭代的 FormGroup 需要一个唯一的 formGroupName,其值就是该 FormGroupFormArray 中的索引值。
<!--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 #</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"></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>

为 FormArray 添加新元素

//src/app/hero-detail.component.ts (addLair method)
addLair() {
  this.secretLairs.push(this.fb.group(new Address()));
}

观测控件的修改

ngOnChanges 无法观测到 hero 中名字,secret lairs 值的修改情况。

不过每个 FormControl 中的 valueChanges 属性,会返回 RxJS Observable,可以进行订阅,从而观测到控件的修改情况:

//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)
  );
}

//Call it in the constructor, after creating the form.
//src/app/hero-detail-8.component.ts
constructor(private fb: FormBuilder) {
  this.createForm();
  this.logNameChange();
}

保存表单数据

保存

当点击提交时,先根据原数据模型中的数据和当前表单模型中的数据(用深度复制的方法)准备好要提交的数据; 提交更新,完成后设置新的数据模型数据;最后调用 ngOnChanges() 刷新表单模型数据:

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

//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;
}

撤销修改

由于当表单模型数据修改后,数据模型不会自动修改,故只有调用 ngOnChanges() 恢复表单模型数据即可:

//src/app/hero-detail.component.ts (revert)
revert() { this.ngOnChanges(); }

参考


上一篇     下一篇