响应式表单

响应式表单是Angular中用响应式风格创建表单的技术。 本章中,我们会在构建“英雄详情编辑器”的过程中,逐步讲解响应式表单的概念。

目录

试试响应式表单的在线例子 / 可下载的例子

你还可以运行响应式表单的演示程序 / 可下载的例子,并从顶部选取一个中间步骤。

响应式表单简介

Angular提供了两种构建表单的技术:响应式表单和模板驱动表单。 这两项技术都属于@angular/forms库,并且共享一组公共的表单控件类。

但是它们在设计哲学、编程风格和具体技术上有显著区别。 所以,它们都有自己的模块:ReactiveFormsModuleFormsModule

响应式表单

Angular的响应式表单能让实现响应式编程风格更容易,这种编程风格更倾向于在非UI的数据模型(通常接收自服务器)之间显式的管理数据流, 并且用一个UI导向的表单模型来保存屏幕上HTML控件的状态和值。 响应式表单可以让使用响应式编程模式、测试和校验变得更容易。

使用响应式表单,我们可以在组件中创建表单控件的对象树,并使用本章中传授的技巧把它们绑定到组件模板中的原生表单控件元素上。

我们可以在组件类中直接创建和维护表单控件对象。由于组件类可以同时访问数据模型和表单控件结构, 因此我们可以把表单模型值的变化推送到表单控件中,并把变化后的值拉取回来。 组件可以监听表单控件状态的变化,并对此做出响应。

直接使用表单控件对象的优点之一是值和有效性状态的更新总是同步的,并且在你的控制之下。 我们不会遇到时序问题,这个问题有时在模板驱动表单中会成为灾难。而且响应式表单更容易进行单元测试。

在响应式编程范式中,组件会负责维护数据模型的不可变性,把模型当做纯粹的原始数据源。 组件不会直接更新数据模型,而是把用户的修改提取出来,把它们转发给外部的组件或服务,外部程序才会使用这些进行处理(比如保存它们), 并且给组件返回一个新的数据模型,以反映模型状态的变化。

使用响应式表单的指令,并不要求你遵循所有的响应式编程原则,但它能让你更容易使用响应式编程方法,从而更愿意使用它。

模板驱动表单

模板一章我们介绍过的模板驱动表单,是一种完全不同的方式。

我们把HTML表单控件(比如<input><select>)放进组件模板中,并用ngModel等指令把它们绑定到组件中数据模型的属性上。

我们不用自己创建Angular表单控件对象。Angular指令会使用数据绑定中的信息创建它们。 我们不用自己推送和拉取数据。Angular使用ngModel来替你管理它们。 当用户做出修改时,Angular会据此更新可变的数据模型

因此,ngModel并不是ReactiveFormsModule模块的一部分。

虽然这意味着组件中的代码更少,但是模板驱动表单是异步工作的,这可能在更高级的场景中让开发复杂化。

异步 vs. 同步

响应式表单是同步的。模板驱动表单是异步的。这个不同点很重要。

使用响应式表单,我们会在代码中创建整个表单控件树。 我们可以立即更新一个值或者深入到表单中的任意节点,因为所有的控件都始终是可用的。

模板驱动表单会委托指令来创建它们的表单控件。 为了消除“检查完后又变化了”的错误,这些指令需要消耗一个以上的变更检测周期来构建整个控件树。 这意味着在从组件类中操纵任何控件之前,我们都必须先等待一个节拍。

比如,如果我们用@ViewChild(NgForm)查询来注入表单控件,并在生命周期钩子ngAfterViewInit中检查它,就会发现它没有子控件。 我们必须使用setTimeout等待一个节拍才能从控件中提取值、测试有效性,或把它设置为新值。

模板驱动表单的异步性让单元测试也变得复杂化了。 我们必须把测试代码包裹在async()fakeAsync()中来解决要查阅的值尚不存在的情况。 使用响应式表单,在所期望的时机一切都是可用的。

哪一个更好?响应式还是模板驱动?

没有哪个“更好”。 它们是两种架构范式,各有优缺点。 请自行选择更合适的方法,甚至可以在同一个应用中同时使用它们。

在这章响应式表单中,我们只专注于响应式范式以及响应式表单技术的详情。

在下一节,我们将先准备一个响应式表单范例的项目,然后就可以开始学习Angular表单类,并在响应式表单中使用它们了。

准备工作

遵循准备工作一章中的步骤基于快速起步种子工程创建一个新的项目目录(比如叫reactive-forms)。

创建数据模型

本章的焦点是响应式表单组件以及编辑一个英雄。 我们需要一个Hero类和一些英雄数据。 在app目录下创建一个data-model.ts文件,并粘贴进下列内容:

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'];

这个文件导出两个类和两个常量。AddressHero类定义应用的数据模型heroesstates常量提供测试数据。

创建响应式表单组件

app目录下创建一个名叫hero-detail.component.ts的新文件,并且导入下列符号:

src/app/hero-detail.component.ts

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

然后输入这个@Component来为HeroDetailComponent指定元数据:

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

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

接下来,创建并导出一个带FormControlHeroDetailComponent类。 FormControl是一个指令,它允许我们直接创建并管理一个FormControl实例。

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

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

这里我们创建了一个名叫nameFormControl。 它将会绑定到模板中的一个input框,表示英雄的名字。

FormControl构造函数接收三个可选参数: 初始值、验证器数组和异步验证器数组。

最简单的控件并不需要数据或验证器,但是在实际应用中,大部分表单控件都会同时具备它们。

本章中只会接触Validators中的一点点,要想更深入的了解它们,请阅读烹饪宝典中的表单验证一章。

创建模板

现在,在创建组件的模板文件src/app/hero-detail.component.html,内容如下:

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>

要让Angular知道我们希望把这个输入框关联到类中的FormControl型属性name,我们需要在模板中的<input>上加一句[formControl]="name"

请忽略CSS类form-control,它属于Bootstrap CSS library而不是Angular。 它会为表单添加样式,但是对表单的逻辑毫无影响。

导入ReactiveFormsModule

HeroDetailComponent的模板中使用了来自ReactiveFormsModuleformControlName

在这个例子中,我们在AppModule中声明了HeroDetailComponent。因此现在app.module.ts中做了三件事:

  1. 使用JavaScript的import语句访问ReactiveFormsModuleHeroDetailComponent

  2. ReactiveFormsModule添加到AppModuleimports列表中。

  3. HeroDetailComponent添加到声明数组中。

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 Angular module imports
  ],
  declarations: [
    AppComponent,
    HeroDetailComponent, // <-- #3 declare app component
  ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

显示HeroDetailComponent

修改AppComponent的模板,以便显示HeroDetailComponent

src/app/app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'my-app',
  template: `
  <div class="container">
    <h1>Reactive Forms</h1>
    <hero-detail></hero-detail>
  </div>`
})
export class AppComponent { }

基础的表单类

阅读一下这些核心表单类的简短描述也许会有用。

随着本章的深入,我们将学到关于这三个类的更多知识。

为应用添加样式

我们在AppComponentHeroDetailComponent的模板中使用Bootstrap中的CSS类。请把bootstrapCSS样式表文件添加到index.htmlhead区。

index.html

<link rel="stylesheet" href="https://unpkg.com/bootstrap@3.3.7/dist/css/bootstrap.min.css">

这些做好之后,浏览器中应该显示成这样:

Single FormControl

添加FormGroup

通常,如果有多个FormControl,我们会希望把它们注册进一个父FormGroup中。这很容易。只要把它加入hero-detail.component.tsimport区就可以了。

src/app/hero-detail.component.ts

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

在这个类中,把FormControl包裹进了一个名叫heroFormFormGroup中,代码如下:

src/app/hero-detail.component.ts

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上。 以前没有父FormGroup的时候,[formControl]="name"也能正常工作,因为该指令可以独立工作,也就是说,不在FormGroup中时它也能用。 有了FormGroupname输入框就需要再添加一个语法formControlName=name,以便让它关联到类中正确的FormControl上。 这个语法告诉Angular,查阅父FormGroup(这里是heroForm),然后在这个FormGroup中查阅一个名叫nameFormControl

请无视CSSform-group,它属于Bootstrap CSS library而不是Angular。 就像form-control类一样,它只是为表单添加样式,而对表单逻辑毫无影响。

表单看起来很棒,但是它能用吗? 当用户输入名字时,它的值去了哪里?

表单模型概览

这个值进入了幕后表单模型中的FormControl构成的表单组。 要想知道表单模型是什么样的,请在hero-detail.component.htmlform标签紧后面添加如下代码:

src/app/hero-detail.component.html

<p>Form value: {{ heroForm.value | json }}</p>
<p>Form status: {{ heroForm.status | json }}</p>

heroForm.value会返回表单模型。 用JsonPipe管道把这个模型以JSON格式渲染到浏览器中。

JSON output

最初的name属性是个空字符串,在name输入框中输入之后,可以看到这些按键出现在了JSON中。

真棒!我们有了一个基本版表单。

在真实的应用中,表单很快就会变大。 FormBuilder能让表单开发和维护变得更简单。

FormBuilder简介

FormBuilder类能通过处理控件创建的细节问题来帮我们减少重复劳动。

要使用FormBuilder,我们就要先把它导入到hero-detail.component.ts中:

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

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

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

修改过的HeroDetailComponent代码如下:

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

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控件的初始值是空字符串。

把一组控件定义在一个单一对象中,可以更加紧凑、易读。 完成相同功能时,这种形式优于一系列new FormControl(...)语句。

Validators.required

虽然本章不会深入讲解验证机制,但还是有一个例子来示范如何简单的在响应式表单中使用Validators.required

首先,导入Validators符号。

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

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

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

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

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

响应式验证器是一些简单、可组合的函数。 在模板驱动表单中配置验证器有些困难,因为我们必须把验证器包装进指令中。

修改模板底部的诊断信息,以显示表单的有效性状态。

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

<p>Form value: {{ heroForm.value | json }}</p>
<p>Form status: {{ heroForm.status | json }}</p>

浏览器会显示下列内容:

Single FormControl

Validators.required生效了,但状态还是INVALID,因为输入框中还没有值。 在输入框中输入,就会看到这个状态从INVALID变成了VALID

在真实的应用中,我们要把这些诊断信息替换成用户友好的信息。

在本章的其余部分,Validators.required是可有可无的,但在每个与此范例配置相同的范例中都会保留它。

要了解Angular表单验证器的更多知识,参见表单验证器一章。

更多的表单控件(FormControl)

每个英雄可以有多个名字,还有一个住址、一项超能力,有时还会有一个副手。

住址中有一个所在州属性,用户将会从<select>框中选择一个州,我们会用<option>元素渲染各个州。我们从data-model.ts中导入states(州列表)。

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

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

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

声明states属性并往heroForm中添加一些表示住址的FormControl,代码如下:

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

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

然后在hero-detail.component.html文件中把对应的脚本添加到form元素中。

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">{{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: {{ heroForm.value | json }}</p>

注意:不用管这些脚本中提到的form-groupform-controlcenter-blockcheckbox等。 它们是来自Bootstrap的CSS类,Angular本身不会管它们。 注意formGroupNameformControlName属性。 他们是Angular指令,用于把相应的HTML控件绑定到组件中的FormGroupFormControl类型的属性上。

修改过的模板包含更多文本输入框,一个state选择框,power(超能力)的单选按钮和一个sidekick检查框。

我们要用[value]="state"来绑定选项的value属性。 如果不绑定这个值,这个选择框就会显示来自数据模型中的第一个选项。

组件定义了控件属性而不用管它们在模板中的表现形式。 我们可以像定义name控件一样定义statepowersidekick控件,并用formControlName指令来指定FormControl的名字。

参见API参考手册中的radio buttonsselectscheckboxes

多级FormGroup

这个表单变得越来越大、越来越笨重。我们可以把一些相关的FormControl组织到多级FormGroup中。 streetcitystatezip属性就可以作为一个名叫addressFormGroup。 用这种方式,多级表单组和控件可以让我们轻松地映射多层结构的数据模型,以便帮助我们跟踪这组相关控件的有效性和状态。

我们用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

要让这个变化更加明显,在文本的顶部加入一个<h4>头:Secret Lair。 新的住址组的HTML如下:

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">{{state}}</option>
      </select>
    </label>
  </div>
  <div class="form-group">
    <label class="center-block">Zip Code:
      <input class="form-control" formControlName="zip">
    </label>
  </div>
</div>

做完这些之后,浏览器中的JSON输出就变成了带有多级FormGroup的住址。

JSON output

真棒!我们制作了一个控件组,并且可以看到模板和表单模型已经能彼此通讯了。

查看FormControl的属性

此刻,我们把整个表单模型展示在了页面里。 但有时我们可能只关心一个特定FormControl的状态。

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

src/app/hero-detail.component.html

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

要点取得FormGroup中的FormControl的状态,使用点语法来指定到控件的路径。

src/app/hero-detail.component.html

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

我们可以使用此技术来显示FromControl任意属性,代码如下:

属性

说明

myControl.value

FormControl的值。

myControl.status

FormControl的有效性。可能的值有VALIDINVALIDPENDINGDISABLED

myControl.pristine

如果用户尚未改变过这个控件的值,则为true。它总是与myControl.dirty相反。

myControl.untouched

如果用户尚未进入这个HTML控件,也没有触发过它的blur(失去焦点)事件,则为true。 它是myControl.touched的反义词。

要了解FormControl的更多属性,参见API参考手册的AbstractControl部分。

检查FormControl属性的另一个原因是确保用户输入了有效的值。 要了解更多关于Angular表单验证的知识,参见表单验证一章。

数据模型表单模型

此刻,表单显示的是空值。 HeroDetailComponent应该显示一个英雄的值,这个值可能接收自远端服务器。

在这个应用中,HeroDetailComponent从它的父组件HeroListComponent中取得一个英雄。

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

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

  1. 开发人员必须理解数据模型是如何映射到表单模型中的属性的。

  2. 用户修改时的数据流是从DOM元素流向表单模型的,而不是数据模型。表单控件永远不会修改数据模型

表单模型数据模型的结构并不需要精确匹配。在一个特定的屏幕上,我们通常只会展现数据模型的一个子集。 但是表单模型的形态越接近数据模型,事情就会越简单。

HeroDetailComponent中,这两个模型是非常接近的。

回忆一下data-model.ts中的Hero定义:

src/app/data-model.ts (classes)

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

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

这里又是组件的FormGroup定义。

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

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

  1. Hero有一个id。表单模型中则没有,因为我们通常不会把主键展示给用户。

  2. Hero有一个住址数组。这个表单模型只表示了一个住址,稍后的修改则可以表示多个。

虽然如此,这两个模型的形态仍然是非常接近的,我们很快就会看到如何用patchValuesetValue方法来把数据模型拷贝到表单模型中。

花一点时间来重构一下address这个FormGroup定义,来让它更简洁清晰,代码如下:

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

为了确保从data-model中导入,我们可以引用HeroAddress类:

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

使用setValuepatchValue来操纵表单模型

以前,我们创建了控件,并同时初始化它的值。 我们也可以稍后用setValuepatchValue来初始化或重置这些值。

setValue

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

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

    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控件。

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

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

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

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

现在,我们已经知道了如何设置表单模型的值,但是什么时候设置它门呢? 答案取决于组件何时得到数据模型的值。

这个响应式表单范例中的HeroDetailComponent组件嵌套在一个主/从结构的HeroListComponent稍后讨论)中。 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">
  <hero-detail [hero]="selectedHero"></hero-detail>
</div>

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

首先,在hero-detail.component.ts中导入OnChangesInput符号。

src/app/hero-detail.component.ts (core imports)

import { Component, Input, OnChanges }             from '@angular/core';

添加输入属性hero

@Input() hero: Hero;

向该类中添加ngOnChanges方法,代码如下:

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,就像这样:

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

创建HeroListComponentHeroService

HeroDetalComponent是一个嵌套在HeroListComponent主从视图中的子组件。如果把它们放在一起就是这样的:

HeroListComponent

HeroListComponent使用一个注入进来的HeroService来从服务器获取英雄列表,然后用一系列按钮把这些英雄展示给用户。 HeroService模拟了HTTP服务。 它返回一个英雄组成的Observable对象,并会在短暂的延迟之后被解析出来,这是为了模拟网络延迟,并展示应用在自然延迟下的异步效果。

当用户点击一个英雄时,组件设置它的selectedHero属性,它绑定到HeroDetailComponent的输入属性hero上。 HeroDetailComponent检测到英雄的变化,并使用当前英雄的值重置此表单。

"刷新"按钮清除英雄列表和当前选中的英雄,然后重新获取英雄列表。

HeroListComponentHeroService的其余部分的实现细节与响应式表单无关。 那些技术涵盖于本文档中的其它部分,包括《英雄指南》中的这里这里

如果你正在随着本教程写代码,可以基于下面显示的代码来创建相应的文件。 注意,hero-list.component.tsrxjs中导入了Observablefinally,而hero.service.ts导入了Observableofdelay。 接下来我们回到正轨,继续学习表单数组属性。

使用FormArray来表示FormGroup数组

以前,我们见过了FormControlFormGroupFormGroup是一个命名对象,它的属性值是FormControl和其它的FormGroup

有时我们得表示任意数量的控件或控件组。 比如,一个英雄可能拥有0、1或任意数量的住址。

Hero.addresses属性就是一个Address实例的数组。 一个住址的FormGroup可以显示一个Address对象。 而FormArray可以显示一个住址FormGroup的数组。

要访问FormArray类,请先把它导入hero-detail.component.ts中:

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

import { Component, Input, OnChanges }                   from '@angular/core';
import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';

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

要想使用FormArray,我们要这么做:

  1. 在数组中定义条目(FormControlFormGroup)。

  2. 把这个数组初始化微一组从数据模型中的数据创建的条目。

  3. 根据用户的需求添加或移除这些条目。

在本章中,我们为Hero.addresses定义了一个FormArray,并且让用户添加或修改这些住址(移除住址功能请课后自行实现)。

我们需要在HeroDetailComponent的构造函数中重新定义表单模型,它现在只用FormGroup显示第一个英雄住址。

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

住址秘密小屋(Secret Lair)

从用户的视角来看,英雄们没有住址。 只有我们凡人才有住址,英雄们拥有的是秘密小屋! 把FormGroup型的住址替换为FormArray型的secretLairs定义:

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

把表单的控件名从address改为secretLairs让我们遇到了一个重要问题:表单模型数据模型不再匹配了。

显然,必须在两者之间建立关联。但它在应用领域中的意义不限于此,它可以用于任何东西。

展现的需求经常会与数据的需求不同。 响应式表单的方法既强调这种差异,也能为这种差异提供了便利。

初始化FormArray型的secretLairs

默认的表单显示一个无地址的无名英雄。

我们需要一个方法来用实际英雄的地址填充(或重新填充)secretLairs, 而不用管父组件HeroListComponent何时把输入属性HeroListComponent.hero设置为一个新的Hero

下面的setAddresses方法把secretLairs数组替换为一个新的FormArray,使用一组表示英雄地址的FormGroup来进行初始化。

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。 我们所要替换的是控件,而不是控件的

还要注意,secretLairs数组中包含的是**FormGroup,而不是Address

获取FormArray

HeroDetailComponent应该能从secretLairs中显示、添加和删除条目。

使用FormGroup.get方法来获取到FormArray的引用。 把这个表达式包装进一个名叫secretLairs的便捷属性中来让它更清晰,并供复用。

src/app/hero-detail.component.ts (secretLayers property)

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

显示FormArray

当前HTML模板显示单个的地址FormGroup。 我们要把它修改成能显示0、1或更多个表示英雄地址的FormGroup

要改的部分主要是把以前表示地址的HTML模板包裹进一个<div>中,并且使用*ngFor来重复渲染这个<div>

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

  1. *ngFor<div>之外套上另一个包装<div>,并且把它的formArrayName指令设为"secretLairs"。 这一步为内部的表单控件建立了一个FormArray型的secretLairs作为上下文,以便重复渲染HTML模板。

  2. 这些重复条目的数据源是FormArray.controls而不是FormArray本身。 每个控件都是一个FormGroup型的地址对象,与以前的模板HTML所期望的格式完全一样。

  3. 每个被重复渲染的FormGroup都需要一个独一无二的formGroupName,它必须是FormGroup在这个FormArray中的索引。 我们将复用这个索引,以便为每个地址组合出一个独一无二的标签。

下面是HTML模板中秘密小屋部分的代码骨架:

src/app/hero-detail.component.html (*ngFor)

<div formArrayName="secretLairs" class="well well-lg">
  <div *ngFor="let address of secretLairs.controls; let i=index" [formGroupName]="i" >
    <!-- The repeated address template -->
  </div>
</div>

这里是秘密小屋部分的完整模板:

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

  1. <div formArrayName="secretLairs" class="well well-lg">
  2. <div *ngFor="let address of secretLairs.controls; let i=index" [formGroupName]="i" >
  3. <!-- The repeated address template -->
  4. <h4>Address #{{i + 1}}</h4>
  5. <div style="margin-left: 1em;">
  6. <div class="form-group">
  7. <label class="center-block">Street:
  8. <input class="form-control" formControlName="street">
  9. </label>
  10. </div>
  11. <div class="form-group">
  12. <label class="center-block">City:
  13. <input class="form-control" formControlName="city">
  14. </label>
  15. </div>
  16. <div class="form-group">
  17. <label class="center-block">State:
  18. <select class="form-control" formControlName="state">
  19. <option *ngFor="let state of states" [value]="state">{{state}}</option>
  20. </select>
  21. </label>
  22. </div>
  23. <div class="form-group">
  24. <label class="center-block">Zip Code:
  25. <input class="form-control" formControlName="zip">
  26. </label>
  27. </div>
  28. </div>
  29. <br>
  30. <!-- End of the repeated address template -->
  31. </div>
  32. </div>

把新的小屋添加到FormArray

添加一个addLair方法,它获取secretLairs数组,并把新的表示地址的FormGroup添加到其中。

src/app/hero-detail.component.ts (addLair method)

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

把一个按钮放在表单中,以便用户可以添加新的秘密小屋,并把它传给组件的addLair方法。

src/app/hero-detail.component.html (addLair button)

<button (click)="addLair()" type="button">Add a Secret Lair</button>

务必确保添加了type="button"属性。 事实上,我们应该总是指定按钮的type。 如果不明确指定类型,按钮的默认类型就是“submit”(提交)。 当我们稍后添加了表单提交的动作时,每个“submit”按钮都是触发一次提交操作,而它将可能会做一些处理,比如保存当前的修改。 我们显然不会希望每当用户点击“Add a Secret Lair”按钮时就保存一次。

试试看!

回到浏览器中,选择名叫“Magneta”的英雄。 "Magneta"没有地址,我们会在表单底部的诊断用JSON中看到这一点。

JSON output of addresses array

点击“Add a Secret Lair”按钮,一个新的地址区就出现了,干得好!

移除一个小屋

这个例子可以添加地址,但是还不能移除它们。 作为练习,你可以自己写一个removeLair方法,并且把它关联到地址HTML模板的一个按钮上。

监视控件的变化

每当用户在父组件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)
  );
}

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

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

logNameChange方法会把一条改名记录追加到nameChangeLog数组中。 用*ngFor绑定在组件模板的底部显示这个数组:

src/app/hero-detail.component.html (Name change log)

<h4>Name change log</h4>
<div *ngFor="let name of nameChangeLog">{{name}}</div>

返回浏览器,选择一个英雄(比如“Magneta”),并开始在姓名输入框中键入。 我们会看到,每次按键都会记录一个新名字。

什么时候用它

插值表达式绑定时显示姓名变化比较简单的方式。 在组件类中订阅表单控件属性变化的可观察对象以触发应用逻辑则是比较难的方式。

保存表单数据

HeroDetailComponent捕获了用户输入,但没有用它做任何事。 在真实的应用中,我们可能要保存这些英雄的变化。 在真实的应用中,我们还要能丢弃未保存的变更,然后继续编辑。 在实现完本节的这些特性之后,表单是这样的:

Form with save & revert buttons

保存

在这个范例应用中,当用户提交表单时,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对象的复本,因此实际上并没有修改原有对象。

丢弃(撤销修改)

用户可以撤销修改,并通过点击Revert按钮来把表单恢复到原始状态。

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

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

revert() { this.ngOnChanges(); }

按钮

把“Save”和“Revert”按钮添加到组件模板的顶部:

src/app/hero-detail.component.html (Save and Revert buttons)

<form [formGroup]="heroForm" (ngSubmit)="onSubmit()" novalidate>
  <div style="margin-bottom: 1em">
    <button type="submit"
            [disabled]="heroForm.pristine" class="btn btn-success">Save</button> &nbsp;
    <button type="reset" (click)="revert()"
            [disabled]="heroForm.pristine" class="btn btn-danger">Revert</button>
  </div>

  <!-- Hero Detail Controls -->
  <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>

这些按钮默认是禁用的,直到用户通过修改任何一个表单控件的值“弄脏”了表单中的数据(即heroForm.dirty)。

点击一个类型为"submit"的按钮会触发ngSubmit事件,而它会调用组件的onSubmit方法。 点击“Revert”按钮则会调用组件的revert方法。 现在,用户可以保存或放弃修改了。

这是本演示的最后一步。 去试试在线例子 / 可下载的例子吧。

总结

本章包括:

最终版中的核心文件如下:

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'my-app',
  4. template: `
  5. <div class="container">
  6. <h1>Reactive Forms</h1>
  7. <hero-list></hero-list>
  8. </div>`
  9. })
  10. export class AppComponent { }

你可以到响应式表单在线例子 / 可下载的例子中下载本章所有步骤的完整代码。