表单

表单是商业应用的支柱,我们用它来执行登录、求助、下单、预订机票、安排会议,以及不计其数的其它数据录入任务。

在开发表单时,创建数据方面的体验是非常重要的,它能指引用户明细、高效的完成工作流程。

开发表单需要设计能力(那超出了本章的范围),而框架支持双向数据绑定、变更检测、验证和错误处理,而本章我们会接触到它们。

这个页面演示了如何从草稿构建一个简单的表单。这个过程中你将学会如何:

你可以在Plunker中运行在线例子 / 可下载的例子,并且从那里下载代码。

模板驱动的表单

通常,使用 Angular 模板语法编写模板,结合本章所描述的表单专用指令和技术来构建表单。

你还可以使用响应式(也叫模型驱动)的方式来构建表单。不过本章中只介绍模板驱动表单。

利用 Angular 模板,可以构建几乎所有表单 — 登录表单、联系人表单…… 以及任何的商务表单。 可以创造性的摆放各种控件、把它们绑定到数据、指定校验规则、显示校验错误、有条件的禁用或 启用特定的控件、触发内置的视觉反馈等等,不胜枚举。

它用起来很简单,这是因为 Angular 处理了大多数重复、单调的任务,这让我们可以不必亲自操刀、身陷其中。

我们将学习构建如下的“模板驱动”表单:

Clean Form

这里是英雄职业介绍所,使用这个表单来维护候选英雄们的个人信息。每个英雄都需要一份工作。 公司的任务就是让适当的英雄去解决它/她所擅长应对的危机!

表单中的三个字段,其中两个是必填的。必填的字段在左侧有个绿色的竖条,方便用户分辨哪些是必填项。

如果删除了英雄的名字,表单就会用醒目的样式把验证错误显示出来。

无效!名字是必填项

注意,提交按钮被禁用了,而且输入控件左侧的“必填”条从绿色变为了红色。

稍后,会使用标准 CSS 来定制“必填”条的颜色和位置。

我们将一点点构建出此表单:

  1. 创建Hero模型类

  2. 创建控制此表单的组件。

  3. 创建具有初始表单布局的模板。

  4. 使用ngModel双向数据绑定语法把数据属性绑定到每个表单输入控件。

  5. 往每个表单输入控件上添加name属性 (attribute)。

  6. 添加自定义 CSS 来提供视觉反馈。

  7. 显示和隐藏有效性验证的错误信息。

  8. 使用 ngSubmit 处理表单提交。

  9. 禁用此表单的提交按钮,直到表单变为有效。

搭建

按照搭建本地开发环境的说明,创建一个名为angular-forms的新项目。

创建 Hero 模型类

当用户输入表单数据时,需要捕获它们的变化,并更新到模型的实例中。 除非知道模型里有什么,否则无法设计表单的布局。

最简单的模型是个“属性包”,用来存放应用中一件事物的事实。 这里使用三个必备字段 (idnamepower),和一个可选字段 (alterEgo,译注:中文含义是第二人格,例如 X 战警中的 Jean / 黑凤凰)。

在应用文件夹中创建下列文件:

src/app/hero.ts

  1. export class Hero {
  2. constructor(
  3. public id: number,
  4. public name: string,
  5. public power: string,
  6. public alterEgo?: string
  7. ) { }
  8. }

这是一个少量需求和零行为的贫血模型。对演示来说很完美。

TypeScript 编译器为每个public构造函数参数生成一个公共字段,在创建新的英雄实例时,自动把参数值赋给这些公共字段。

alterEgo是可选的,调用构造函数时可省略,注意alterEgo?中的问号 (?)。

可以这样创建新英雄:

let myHero =  new Hero(42, 'SkyDog',
                       'Fetch any object at any distance',
                       'Leslie Rollover');
console.log('My hero is called ' + myHero.name); // "My hero is called SkyDog"

创建表单组件

Angular 表单分为两部分:基于 HTML 的模板和组件,用来程序处理数据和用户交互。 先从组件类开始,是因为它可以简要说明英雄编辑器能做什么。

创建下列文件:

src/app/hero-form.component.ts (v1)

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

import { Hero }    from './hero';

@Component({
  selector: 'hero-form',
  templateUrl: './hero-form.component.html'
})
export class HeroFormComponent {

  powers = ['Really Smart', 'Super Flexible',
            'Super Hot', 'Weather Changer'];

  model = new Hero(18, 'Dr IQ', this.powers[0], 'Chuck Overstreet');

  submitted = false;

  onSubmit() { this.submitted = true; }

  // TODO: Remove this when we're done
  get diagnostic() { return JSON.stringify(this.model); }
}

这个组件没有什么特别的地方,没有表单相关的东西,与之前写过的组件没什么不同。

只需要前面章节中学过的概念,就可以完全理解这个组件:

接下来,我们可以注入一个数据服务,以获取或保存真实的数据,或者把这些属性暴露为输入属性和输出属性(参见Template Syntax中的输入和输出属性)来绑定到一个父组件。这不是现在需要关心的问题,未来的更改不会影响到这个表单。

为何分离模板文件?

为什么不与我们在其他地方常常做的那样,以内联的方式把模板写在组件文件中呢?

没有什么答案在所有场合都总是“正确”的。当模板足够短的时候,内联形式更招人喜欢。 但大多数的表单模板都不短。通常,TypeScript 和 JavaScript 文件不是写(读)大型 HTML 的好地方, 而且没有几个编辑器能对混写的 HTML 和代码提供足够的帮助。 我们还是喜欢内容清晰、目标明确的短文件,像这个一样。

就算是在仅仅显示少数表单项目时,表单模板一般都比较庞大。所以通常最好的方式是将 HTML 模板放到单独的文件中。 一会儿将编写这个模板文件。在这之前,先退一步,再看看app.module.tsapp.component.ts,让它们使用新的HeroFormComponent

修改 app.module.ts

app.module.ts定义了应用的根模块。其中标识即将用到的外部模块,以及声明属于本模块中的组件,例如HeroFormComponent

因为模板驱动的表单位于它们自己的模块,所以在使用表单之前,需要将FormsModule添加到应用模块的imports数组中。

把“快速起步”版的文件替换为如下内容:

src/app/app.module.ts

  1. import { NgModule } from '@angular/core';
  2. import { BrowserModule } from '@angular/platform-browser';
  3. import { FormsModule } from '@angular/forms';
  4. import { AppComponent } from './app.component';
  5. import { HeroFormComponent } from './hero-form.component';
  6. @NgModule({
  7. imports: [
  8. BrowserModule,
  9. FormsModule
  10. ],
  11. declarations: [
  12. AppComponent,
  13. HeroFormComponent
  14. ],
  15. bootstrap: [ AppComponent ]
  16. })
  17. export class AppModule { }

有三处更改:

  1. 导入FormsModule和新组件HeroFormComponent

  2. FormsModule添加到ngModule装饰器的imports列表中,这样应用就能访问模板驱动表单的所有特性,包括ngModel

  3. HeroFormComponent添加到ngModule装饰器的declarations列表中,使HeroFormComponent组件在整个模块中可见。

如果组件、指令或管道出现在模块的imports数组中,不要把它声明在declarations数组中。 如果它是你自己写的,并且属于当前模块,就要把它声明在declarations数组中。

修改 app.component.ts

AppComponent是应用的根组件,HeroFormComponent将被放在其中。

把“快速起步”的版本内容替换成下列代码:

src/app/app.component.ts

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'my-app',
  4. template: '<hero-form></hero-form>'
  5. })
  6. export class AppComponent { }

这里只做了两处修改。 template中只剩下这个新的元素标签,即组件的selector属性。这样当应用组件被加载时,就会显示这个英雄表单。 另外,我们还从类中移除了name字段。

创建初始 HTML 表单模板

用下列内容新建模板文件:

src/app/hero-form.component.html

  1. <div class="container">
  2. <h1>Hero Form</h1>
  3. <form>
  4. <div class="form-group">
  5. <label for="name">Name</label>
  6. <input type="text" class="form-control" id="name" required>
  7. </div>
  8. <div class="form-group">
  9. <label for="alterEgo">Alter Ego</label>
  10. <input type="text" class="form-control" id="alterEgo">
  11. </div>
  12. <button type="submit" class="btn btn-success">Submit</button>
  13. </form>
  14. </div>

这只是一段普通的旧式 HTML 5 代码。这里有两个Hero字段,namealterEgo,供用户输入。

Name <input>控件具有 HTML5 的required属性;但 Alter Ego <input>控件没有,因为alterEgo字段是可选的。

在底部添加个 Submit 按钮,它还带一些 CSS 样式类。

我们还没有真正用到Angular。没有绑定,没有额外的指令,只有布局。

在模板驱动表单中,你只要导入了FormsModule就不用对<form>做任何改动来使用FormsModule。接下来你会看到它的原理。

containerform-groupform-controlbtn类来自 Twitter Bootstrap。纯粹是装饰。 我们使用 Bootstrap 来美化表单。嘿,一点样式都没有的表单算个啥!

Angular 表单不需要任何样式库

Angular 不需要containerform-groupform-controlbtn类, 或者外部库的任何样式。Angular 应用可以使用任何 CSS 库…… ,或者啥都不用。

我们来添加样式表。打开index.html,并把下列链接添加到<head>中:

src/index.html (bootstrap)

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

ngFor 添加超能力

我们的英雄必须从认证过的固定列表中选择一项超能力。 这个列表位于HeroFormComponent中。

在表单中添加select,用ngForpowers列表绑定到列表选项。 我们在之前的显示数据一章中见过ngFor

Alter Ego 的紧下方添加如下 HTML:

src/app/hero-form.component.html (powers)

<div class="form-group">
  <label for="power">Hero Power</label>
  <select class="form-control" id="power" required>
    <option *ngFor="let pow of powers" [value]="pow">{{pow}}</option>
  </select>
</div>

列表中的每一项超能力都会渲染成<option>标签。 模板输入变量p在每个迭代指向不同的超能力,使用双花括号插值表达式语法来显示它的名称。

使用 ngModel 进行双向数据绑定

如果立即运行此应用,你将会失望。

没有数据绑定的早期表单

因为还没有绑定到某个英雄,所以看不到任何数据。 解决方案见前面的章节。 显示数据介绍了属性绑定。 用户输入介绍了如何通过事件绑定来监听 DOM 事件,以及如何用显示值更新组件的属性。

现在,需要同时进行显示、监听和提取。

虽然可以在表单中再次使用这些技术。 但是,这里将介绍个新东西,[(ngModel)]语法,使表单绑定到模型的工作变得超级简单。

找到 Name 对应的<input>标签,并且像这样修改它:

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

<input type="text" class="form-control" id="name"
       required
       [(ngModel)]="model.name" name="name">
TODO: remove this: {{model.name}}

在 input 标签后添加用于诊断的插值表达式,以看清正在发生什么事。 给自己留个备注,提醒我们完成后移除它。

聚焦到绑定语法[(ngModel)]="..."上。

我们需要更多的工作来显示数据。在表单中声明一个模板变量。往<form>标签中加入#heroForm="ngForm",代码如下:

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

<form #heroForm="ngForm">

heroForm变量是一个到NgForm指令的引用,它代表该表单的整体。

NgForm指令

什么是NgForm指令? 但我们明明没有添加过NgForm指令啊!

Angular替你做了。Angular会在<form>标签上自动创建并附加一个NgForm指令。

NgForm指令为form增补了一些额外特性。 它会控制那些带有ngModel指令和name属性的元素,监听他们的属性(包括其有效性)。 它还有自己的valid属性,这个属性只有在它包含的每个控件都有效时才是真。

如果现在运行这个应用,开始在姓名输入框中键入,添加和删除字符,将看到它们从插值结果中显示和消失。 某一瞬间,它可能是这样的:

操作中的ngModel

诊断信息可以证明,数据确实从输入框流动到模型,再反向流动回来。

这就是双向数据绑定!要了解更多信息,参见模板语法页的使用NgModel进行双向绑定

注意,<input>标签还添加了name属性 (attribute),并设置为 "name",表示英雄的名字。 使用任何唯一的值都可以,但使用具有描述性的名字会更有帮助。 当在表单中使用[(ngModel)]时,必须要定义name属性。

在内部,Angular 创建了一些FormControl,并把它们注册到NgForm指令,再将该指令附加到<form>标签。 注册每个FormControl时,使用name属性值作为键值。本章后面会讨论NgForm

第二人格超能力属性添加类似的[(ngModel)]绑定和name属性。 抛弃输入框的绑定消息,在组件顶部添加到diagnostic属性的新绑定。 这样就能确认双向数据绑定在整个 Hero 模型上都能正常工作了。

修改之后,这个表单的核心是这样的:

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

{{diagnostic}}
<div class="form-group">
  <label for="name">Name</label>
  <input type="text" class="form-control" id="name"
         required
         [(ngModel)]="model.name" name="name">
</div>

<div class="form-group">
  <label for="alterEgo">Alter Ego</label>
  <input type="text"  class="form-control" id="alterEgo"
         [(ngModel)]="model.alterEgo" name="alterEgo">
</div>

<div class="form-group">
  <label for="power">Hero Power</label>
  <select class="form-control"  id="power"
          required
          [(ngModel)]="model.power" name="power">
    <option *ngFor="let pow of powers" [value]="pow">{{pow}}</option>
  </select>
</div>

如果现在运行本应用,修改 Hero 模型的每个属性,表单是这样的:

ngModel in action

表单顶部的诊断信息反映出所做的一切更改。

表单顶部的{{diagnostic}}绑定已经完成了它的使命,删除它。

通过 ngModel 跟踪修改状态与有效性验证

在表单中使用ngModel可以获得比仅使用双向数据绑定更多的控制权。它还会告诉我们很多信息:用户碰过此控件吗?它的值变化了吗?数据变得无效了吗?

NgModel 指令不仅仅跟踪状态。它还使用特定的 Angular CSS 类来更新控件,以反映当前状态。 可以利用这些 CSS 类来修改控件的外观,显示或隐藏消息。

状态

为真时的 CSS 类

为假时的 CSS 类

控件被访问过。

ng-touchedng-untouched

控件的值变化了。

ng-dirtyng-pristine

控件的值有效。

ng-validng-invalid

往姓名<input>标签上添加名叫 spy 的临时模板引用变量, 然后用这个 spy 来显示它上面的所有 CSS 类。

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

<input type="text" class="form-control" id="name"
  required
  [(ngModel)]="model.name" name="name"
  #spy>
<br>TODO: remove this: {{spy.className}}

现在,运行本应用,并让姓名输入框获得焦点。 然后严格按照下面四个步骤来做:

  1. 查看输入框,但别碰它。

  2. 点击输入框,然后点击输入框外面。

  3. 在名字的末尾添加些斜杠。

  4. 删除名字。

动作和它对应的效果如下:

控件状态转换

我们会看到下列转换及其类名:

Control state transitions

(ng-valid | ng-invalid)这一对是我们最感兴趣的。当数据变得无效时,我们希望发出强力的视觉信号, 还想要标记出必填字段。可以通过加入自定义 CSS 来提供视觉反馈。

删除模板引用变量#spyTODO,因为它们已经完成了使命。

添加用于视觉反馈的自定义 CSS

可以在输入框的左侧添加带颜色的竖条,用于标记必填字段和无效输入:

无效表单

在新建的forms.css文件中,添加两个样式来实现这一效果。把这个文件添加到项目中,与index.html相邻。

src/forms.css

  1. .ng-valid[required], .ng-valid.required {
  2. border-left: 5px solid #42A948; /* green */
  3. }
  4. .ng-invalid:not(form) {
  5. border-left: 5px solid #a94442; /* red */
  6. }

更新index.html中的<head>,以包含这个样式表:

src/index.html (styles)

<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="forms.css">

显示和隐藏验证错误信息

我们能做的更好。“Name” 输入框是必填的,清空它会让左侧的条变红。这表示某些东西是错的,但我们不知道错在哪里,或者如何纠正。 可以借助ng-invalid类来给出有用的提示。

当用户删除姓名时,应该是这样的:

必须填写姓名

要达到这个效果,在<input>标签中添加:

这个例子中我们把一条错误信息添加到了name输入框中:

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

        <label for="name">Name</label>
        <input type="text" class="form-control" id="name"
               required
               [(ngModel)]="model.name" name="name"
               #name="ngModel">
        <div [hidden]="name.valid || name.pristine"
             class="alert alert-danger">
          Name is required
        </div>

模板引用变量可以访问模板中输入框的 Angular 控件。 这里,创建了名叫name的变量,并且赋值为 "ngModel"。

为什么是 “ngModel”? 指令的 exportAs 属性告诉 Angular 如何链接模板引用变量到指令。 这里把name设置为ngModel是因为ngModel指令的exportAs属性设置成了 “ngModel”。

我们把div元素的hidden属性绑定到name控件的属性,这样就可以控制“姓名”字段错误信息的可见性了。

<div [hidden]="name.valid || name.pristine"
     class="alert alert-danger">

上例中,当控件是有效的 (valid) 或全新的 (pristine) 时,隐藏消息。 “全新的”意味着从它被显示在表单中开始,用户还从未修改过它的值。

这种用户体验取决于开发人员的选择。有些人会希望任何时候都显示这条消息。 如果忽略了pristine状态,就会只在值有效时隐藏此消息。 如果往这个组件中传入全新(空)的英雄,或者无效的英雄,将立刻看到错误信息 —— 虽然我们还啥都没做。

有些人会为这种行为感到不安。它们希望只有在用户做出无效的更改时才显示这个消息。 如果当控件是“全新”状态时也隐藏消息,就能达到这个目的。 在往表单中添加新英雄时,将看到这种选择的重要性。

英雄的第二人格是可选项,所以不用改它。

英雄的超能力选项是必填的。 只要愿意,可以往<select>上添加相同的错误处理。 但没有必要,这个选择框已经限制了“超能力”只能选有效值。

我们希望在这个表单中添加新的英雄。 在表单的底部放置“New Hero(新增英雄)”按钮,并把它的点击事件绑定到newHero组件。

src/app/hero-form.component.html (New Hero button)

<button type="button" class="btn btn-default" (click)="newHero()">New Hero</button>

src/app/hero-form.component.ts (New Hero method)

newHero() {
  this.model = new Hero(42, '', '');
}

再次运行应用,点击 New Hero 按钮,表单被清空了。 输入框左侧的必填项竖条是红色的,表示namepower属性是无效的。 这可以理解,因为有一些必填字段。 错误信息是隐藏的,因为表单还是全新的,还没有修改任何东西。

输入名字,再次点击 New Hero 按钮。 这次,出现了错误信息!为什么?我们不希望显示新(空)的英雄时,出现错误信息。

使用浏览器工具审查这个元素就会发现,这个 name 输入框并不是全新的。 表单记得我们在点击 New Hero 前输入的名字。 更换了英雄并不会重置控件的“全新”状态

我们必须清除所有标记,在调用newHero()方法后调用表单的reset()方法即可。

src/app/hero-form.component.html (Reset the form)

<button type="button" class="btn btn-default" (click)="newHero(); heroForm.reset()">New Hero</button>

现在点击“New Hero”重设表单和它的控制标记。

使用 ngSubmit 提交该表单

在填表完成之后,用户还应该能提交这个表单。 “Submit(提交)”按钮位于表单的底部,它自己不做任何事,但因为有特殊的 type 值 (type="submit"),所以会触发表单提交。

现在这样仅仅触发“表单提交”是没用的。 要让它有用,就要把该表单的ngSubmit事件属性绑定到英雄表单组件的onSubmit()方法上:

<form (ngSubmit)="onSubmit()" #heroForm="ngForm">

我们已经定义了一个模板引用变量#heroForm,并且把赋值为“ngForm”。 现在,就可以在“Submit”按钮中访问这个表单了。

我们要把表单的总体有效性通过heroForm变量绑定到此按钮的disabled属性上,代码如下:

<button type="submit" class="btn btn-success" [disabled]="!heroForm.form.valid">Submit</button>

重新运行应用。表单打开时,状态是有效的,按钮是可用的。

现在,如果我们删除姓名,就会违反“必填姓名”规则,就会像以前那样显示出错误信息。同时,Submit 按钮也被禁用了。

没感动吗?再想一会儿。如果没有 Angular NgForm的帮助,又该怎么让按钮的禁用/启用状态和表单的有效性关联起来呢?

有了 Angular,它就是这么简单:

  1. 定义模板引用变量,放在(强化过的)form 元素上

  2. 从很多行之外的按钮上引用这个变量。

切换两个表单区域(额外的奖励)

提交表单还是不够激动人心。

对演示来说,这个收场很平淡的。老实说,即使让它更出彩,也无法教给我们任何关于表单的新知识。 但这是练习新学到的绑定技能的好机会。 如果你不感兴趣,可以跳到本章的总结部分。

来实现一些更炫的视觉效果吧。 隐藏掉数据输入框,显示一些其它东西。

先把表单包裹进<div>中,再把它的hidden属性绑定到HeroFormComponent.submitted属性。

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

  <div [hidden]="submitted">
    <h1>Hero Form</h1>
    <form (ngSubmit)="onSubmit()" #heroForm="ngForm">

       <!-- ... all of the form ... -->

    </form>
  </div>

主表单从一开始就是可见的,因为submitted属性是 false,直到提交了这个表单。 来自HeroFormComponent的代码片段告诉了我们这一点:

src/app/hero-form.component.ts (submitted)

submitted = false;

onSubmit() { this.submitted = true; }

当点击 Submit 按钮时,submitted标志会变成 true,并且表单像预想中一样消失了。

现在,当表单处于已提交状态时,需要显示一些别的东西。 在刚刚写的<div>包装下方,添加下列 HTML 语句:

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

<div [hidden]="!submitted">
  <h2>You submitted the following:</h2>
  <div class="row">
    <div class="col-xs-3">Name</div>
    <div class="col-xs-9  pull-left">{{ model.name }}</div>
  </div>
  <div class="row">
    <div class="col-xs-3">Alter Ego</div>
    <div class="col-xs-9 pull-left">{{ model.alterEgo }}</div>
  </div>
  <div class="row">
    <div class="col-xs-3">Power</div>
    <div class="col-xs-9 pull-left">{{ model.power }}</div>
  </div>
  <br>
  <button class="btn btn-primary" (click)="submitted=false">Edit</button>
</div>

英雄又出现了,它通过插值表达式绑定显示为只读内容。 这一小段 HTML 只在组件处于已提交状态时才会显示。

这段HTML包含一个 “Edit(编辑)”按钮,将 click 事件绑定到表达式,用于清除submitted标志。

当点Edit按钮时,这个只读块消失了,可编辑的表单重新出现了。

结论

本章讨论的 Angular 表单技术利用了下列框架特性来支持数据修改、验证和更多操作:

最终的项目目录结构是这样的:

angular-forms
src
app
app.component.ts
app.module.ts
hero.ts
hero-form.component.html
hero-form.component.ts
main.ts
tsconfig.json
index.html
node_modules ...
package.json

这里是源码的最终版本:

  1. import { Component } from '@angular/core';
  2. import { Hero } from './hero';
  3. @Component({
  4. selector: 'hero-form',
  5. templateUrl: './hero-form.component.html'
  6. })
  7. export class HeroFormComponent {
  8. powers = ['Really Smart', 'Super Flexible',
  9. 'Super Hot', 'Weather Changer'];
  10. model = new Hero(18, 'Dr IQ', this.powers[0], 'Chuck Overstreet');
  11. submitted = false;
  12. onSubmit() { this.submitted = true; }
  13. newHero() {
  14. this.model = new Hero(42, '', '');
  15. }
  16. }

下一步

依赖注入