表单验证

我们可以通过验证用户输入的准确性和完整性,来增强整体数据质量。

在本烹饪书中,我们展示在界面中如何验证用户输入,并显示有用的验证信息,先使用模板驱动表单方式,再使用响应式表单方式。

参见表单响应式表单了解关于这些选择的更多知识。

目录

查看在线例子,并下载整个烹饪书的源代码

plunker

You can also download this example.

简单的模板驱动表单

在模板驱动表单方法中,你在组件的模板中组织表单元素

你可以添加Angular表单指令(通常为以ng开头的指令)来帮助Angular构建对应的内部控制模型,以实现表单功能。 控制模型在模板中是隐式的。

要验证用户输入,你添加HTML验证属性到元素中。 Angular拦截这些元素,添加验证器函数到控制模型中。

Angular暴露关于控制状态的信息,包括用户是否已经“触摸“了控制器,或者用户已经作了更新和控制器的值是否还有效。

在第一个模板验证例子中,我们添加了更多HTML,来读取控制器状态并适当更新显示。 下面是模板HTML中提取的,一个绑定到英雄名字的输入框控制器:

template/hero-form-template1.component.html (Hero name)

<label for="name">Name</label>

<input type="text" id="name" class="form-control"
       required minlength="4" maxlength="24"
       name="name" [(ngModel)]="hero.name"
       #name="ngModel" >

<div *ngIf="name.errors && (name.dirty || name.touched)"
     class="alert alert-danger">
    <div [hidden]="!name.errors.required">
      Name is required
    </div>
    <div [hidden]="!name.errors.minlength">
      Name must be at least 4 characters long.
    </div>
    <div [hidden]="!name.errors.maxlength">
      Name cannot be more than 24 characters long.
    </div>
</div>

请注意以下几点:

整个模板为表单上的每种数据输入控制器重复这种布局。

为何检查dirtytouched

当用户创建一个新英雄时,在还没有机会输入之前,我们不应该显示任何错误。 检查dirtytouched防止了这种过早的错误显示。

参见表单章,学习关于dirtytouched的知识。

组件类管理用于数据绑定的英雄模型,它还有其他支持视图的代码。

template/hero-form-template1.component.ts (class)

  1. export class HeroFormTemplate1Component {
  2. powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
  3. hero = new Hero(18, 'Dr. WhatIsHisWayTooLongName', this.powers[0], 'Dr. What');
  4. submitted = false;
  5. onSubmit() {
  6. this.submitted = true;
  7. }
  8. addHero() {
  9. this.hero = new Hero(42, '', '');
  10. }
  11. }

在处理简单的、拥有标准验证规则的静态表单时,使用这种模板驱动验证方法。

下面是第一个版本的使用模板驱动方法的HeroFormTemplateComponent

  1. <div class="container">
  2. <div [hidden]="submitted">
  3. <h1>Hero Form 1 (Template)</h1>
  4. <form #heroForm="ngForm" *ngIf="active" (ngSubmit)="onSubmit()">
  5. <div class="form-group">
  6. <label for="name">Name</label>
  7. <input type="text" id="name" class="form-control"
  8. required minlength="4" maxlength="24"
  9. name="name" [(ngModel)]="hero.name"
  10. #name="ngModel" >
  11. <div *ngIf="name.errors && (name.dirty || name.touched)"
  12. class="alert alert-danger">
  13. <div [hidden]="!name.errors.required">
  14. Name is required
  15. </div>
  16. <div [hidden]="!name.errors.minlength">
  17. Name must be at least 4 characters long.
  18. </div>
  19. <div [hidden]="!name.errors.maxlength">
  20. Name cannot be more than 24 characters long.
  21. </div>
  22. </div>
  23. </div>
  24. <div class="form-group">
  25. <label for="alterEgo">Alter Ego</label>
  26. <input type="text" id="alterEgo" class="form-control"
  27. name="alterEgo"
  28. [(ngModel)]="hero.alterEgo" >
  29. </div>
  30. <div class="form-group">
  31. <label for="power">Hero Power</label>
  32. <select id="power" class="form-control"
  33. name="power"
  34. [(ngModel)]="hero.power" required
  35. #power="ngModel" >
  36. <option *ngFor="let p of powers" [value]="p">{{p}}</option>
  37. </select>
  38. <div *ngIf="power.errors && power.touched" class="alert alert-danger">
  39. <div [hidden]="!power.errors.required">Power is required</div>
  40. </div>
  41. </div>
  42. <button type="submit" class="btn btn-default"
  43. [disabled]="!heroForm.form.valid">Submit</button>
  44. <button type="button" class="btn btn-default"
  45. (click)="addHero()">New Hero</button>
  46. </form>
  47. </div>
  48. <hero-submitted [hero]="hero" [(submitted)]="submitted"></hero-submitted>
  49. </div>

验证消息在代码中的模板驱动表单

虽然布局很直观,但是我们处理验证消息的方法有明显的缺陷:

只需要对模板和组件做出一些修改,我们可以将逻辑和消息移到组件中。

下面也是关于英雄名字的控制器,从修改后的模板(“Template 2”)中抽取出来,与原来的版本相比:

  1. <label for="name">Name</label>
  2. <input type="text" id="name" class="form-control"
  3. required minlength="4" maxlength="24" forbiddenName="bob"
  4. name="name" [(ngModel)]="hero.name" >
  5. <div *ngIf="formErrors.name" class="alert alert-danger">
  6. {{ formErrors.name }}
  7. </div>

<input>元素的HTML几乎一样。但是下列有值得注意的区别:

组件类

原组件代码的模板一没变化,只是模板二发生了变化。本节包括模板二的组件类,以获取Angular表单控制器和撰写错误信息。

第一步是获取Angular通过查询模板而生成的表单控制器。

回头看组件模板顶部,我们在<form>元素中设置#heroForm模板变量:

template/hero-form-template1.component.html (form tag)

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

heroFrom变量是Angular从模板衍生出来的控制模型的引用。 我们利用@ViewChild来告诉Angular注入这个模型到组件类的currentForm属性:

template/hero-form-template2.component.ts (heroForm)

heroForm: NgForm;
@ViewChild('heroForm') currentForm: NgForm;

ngAfterViewChecked() {
  this.formChanged();
}

formChanged() {
  if (this.currentForm === this.heroForm) { return; }
  this.heroForm = this.currentForm;
  if (this.heroForm) {
    this.heroForm.valueChanges
      .subscribe(data => this.onValueChanged(data));
  }
}

一些细节:

template/hero-form-template2.component.ts (handler)

onValueChanged(data?: any) {
  if (!this.heroForm) { return; }
  const form = this.heroForm.form;

  for (const field in this.formErrors) {
    // clear previous error message (if any)
    this.formErrors[field] = '';
    const control = form.get(field);

    if (control && control.dirty && !control.valid) {
      const messages = this.validationMessages[field];
      for (const key in control.errors) {
        this.formErrors[field] += messages[key] + ' ';
      }
    }
  }
}

formErrors = {
  'name': '',
  'power': ''
};

onValueChanged处理器拦截用户数据输入。 包含当前元素值得data对象被传入处理器。 处理器忽略它们。相反,它迭代组件的formErrors对象。

formErrors是一个词典,包含了拥有验证规则和当前错误消息的英雄控件。 只有两个英雄属性有验证规则,namepower。 当英雄数据有效时,这些消息的值为空字符串。

对于每个字段,这个onValueChanged处理器会做这些:

很显然,我们需要一些错误消息,每个验证的属性都需要一套,每个验证规则需要一条消息:

template/hero-form-template2.component.ts (messages)

validationMessages = {
  'name': {
    'required':      'Name is required.',
    'minlength':     'Name must be at least 4 characters long.',
    'maxlength':     'Name cannot be more than 24 characters long.',
    'forbiddenName': 'Someone named "Bob" cannot be a hero.'
  },
  'power': {
    'required': 'Power is required.'
  }
};

现在,每次用户作出变化时,onValueChanged处理器检查验证错误并按情况发出错误消息。

在代码中写消息的优点

很显然,模板变得小多了,组件代码变得大多了。当只有三个控件并且其中只有两个有验证规则时,我们很难看出好处。

假设增加需要验证的控件和规则后会怎么样。 通常,HTML比代码更难阅读和维护。 初始的模板已经很大了,如果我们添加更多验证消息<div>,它会迅速变得更大。

将验证消息移到组件后,模板的增长变得更加缓慢,幅度也小一些。 不管有多少个验证规则,每个控件的行数是差不多的。 组件也按比例增长,每增加一个控件增加一行,每个验证消息一行。

两条线容易维护。

现在消息在代码中,我们有更多的灵活度。我们更加智能的撰写消息。 我们可以将消息重构出组件,比如到一个服务类,从服务端获取消息。 简而言之,有很多机会增强消息处理,因为文本和逻辑都已经从模板移到代码中。

FormModule 和模板驱动表单

Angular有两种不同的表单模块 - FormsModuleReactiveFormsModule - 它们与表单开发的两种方法对应。 两种模块都从同一个@angular/forms库。

我们一直在探讨模板驱动方法,它需要FormsModule。下面是如何在HeroFormTemplateModule中导入它:

template/hero-form-template.module.ts

import { NgModule }     from '@angular/core';
import { FormsModule }  from '@angular/forms';

import { SharedModule }               from '../shared/shared.module';
import { HeroFormTemplate1Component } from './hero-form-template1.component';
import { HeroFormTemplate2Component } from './hero-form-template2.component';

@NgModule({
  imports:      [ SharedModule, FormsModule ],
  declarations: [ HeroFormTemplate1Component, HeroFormTemplate2Component ],
  exports:      [ HeroFormTemplate1Component, HeroFormTemplate2Component ]
})
export class HeroFormTemplateModule { }

我们还没有讲SharedModule或者它的SubmittedComponent,它们出现在本烹饪书的每一个表单模板中。

它们与表单验证没有紧密的关系。如果你感兴趣,参见在线例子

在代码中验证响应式表单

在模板驱动方法中,你在模板中标出表单元素、验证属性和AngularFormsModule中的ng...指令。 在运行时间,Angular解释模板并从表单控制器模型衍生它。

响应式表单采用不同的方法。 你在代码中创建表单控制器模型,并用表单元素和来自Angular ReactiveFormsModule中的form...指令来编写模板。 在运行时间,Angular根据你的指示绑定模板元素到你的控制器模型。

这个方法需要做一些额外的工作。你必须编写并管理控制器模型*

这可以让你:

第三个烹饪书例子用响应式表单风格重新编写英雄表格。

切换到ReactiveFormsModule

响应式表单类和指令来自于Angular的ReactiveFormsModule,不是FormsModule。 本例中,应用模块的“响应式表单”特性是这样的:

src/app/reactive/hero-form-reactive.module.ts

import { NgModule }            from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

import { SharedModule }              from '../shared/shared.module';
import { HeroFormReactiveComponent } from './hero-form-reactive.component';

@NgModule({
  imports:      [ SharedModule, ReactiveFormsModule ],
  declarations: [ HeroFormReactiveComponent ],
  exports:      [ HeroFormReactiveComponent ]
})
export class HeroFormReactiveModule { }

“响应式表单”特性模块和组件在app/reactive目录。 让我们关注那里的HeroFormReactiveComponent,先看它的模板。

组件模板

我们先修改<form>标签,让Angular的formGroup指令绑定到组件类的heroForm属性。 heroForm是组件类创建和维护的控制器模型。

<form [formGroup]="heroForm"  *ngIf="active"  (ngSubmit)="onSubmit()">

接下来,我们修改模板HTML元素,来匹配响应式表单样式。 下面又是“name”部分的模板,响应式表单修改版本和模板驱动版本的比较:

  1. <label for="name">Name</label>
  2. <input type="text" id="name" class="form-control"
  3. formControlName="name" required >
  4. <div *ngIf="formErrors.name" class="alert alert-danger">
  5. {{ formErrors.name }}
  6. </div>

关键变化:

未来版本的响应式表单将会在控制器有required验证器函数时,添加required HTML验证属性到DOM元素(也可能添加aria-required属性)。

在此之前,添加required属性以及添加Validator.required函数到控制器模型,像我们下面这样做:

不适用表单数据绑定是响应式模式的原则,而非技术限制。

组件类

组件类现在负责定义和管理表单控制器模型。

Angular不再从模板衍生控制器模型,所以我们不能再查询它。 我们利用FormBuilder来显式创建Angular表单控制器模型。

下面是负责该进程的代码部分,与被它取代的模板驱动代码相比:

  1. heroForm: FormGroup;
  2. constructor(private fb: FormBuilder) { }
  3. ngOnInit(): void {
  4. this.buildForm();
  5. }
  6. buildForm(): void {
  7. this.heroForm = this.fb.group({
  8. 'name': [this.hero.name, [
  9. Validators.required,
  10. Validators.minLength(4),
  11. Validators.maxLength(24),
  12. forbiddenNameValidator(/bob/i)
  13. ]
  14. ],
  15. 'alterEgo': [this.hero.alterEgo],
  16. 'power': [this.hero.power, Validators.required]
  17. });
  18. this.heroForm.valueChanges
  19. .subscribe(data => this.onValueChanged(data));
  20. this.onValueChanged(); // (re)set validation messages now
  21. }

真实的应用很可能从数据服务异步获取英雄,这个任务最好在ngOnInit生命周期钩子中进行。

FormBuilder声明

FormBuilder声明对象指定了本例英雄表单的三个控制器。

每个控制器的设置都是控制器名字和数组值。 第一个数组元素是英雄控件对应的当前值。 第二个值(可选)是验证器函数或者验证器函数数组。

大多数验证器函数是Angular以Validators类的静态方法的形式提供的原装验证器。 Angular有一些原装验证器,与标准HTML验证属性一一对应。

"name"控制器上的forbiddenNames验证器是自定义验证器,在下面单独的小结有所讨论。

到[响应式表单]的FormBuilder介绍部分,学习更多关于FormBuilder的知识。

提交英雄值的更新

在双向数据绑定时,用户的修改自动从控制器流向数据模型属性。 响应式表单不适用数据绑定来更新数据模型属性。 开发者决定何时如何从控制器的值更新数据模型。

本例更新模型两次:

  1. 当用户提交标单时

  2. 当用户添加新英雄时

onSubmit()方法直接使用表单的值得合集来替换hero对象:

onSubmit() {
  this.submitted = true;
  this.hero = this.heroForm.value;
}

本例非常“幸运”,因为heroForm.value属性正好与英雄数据对象属性对应。

addHero()方法放弃未处理的变化,并创建一个崭新的hero模型对象。

addHero() {
  this.hero = new Hero(42, '', '');
  this.buildForm();
}

然后它再次调用buildForm,用一个新对象替换了之前的heroForm控制器模型。 <form>标签的[formGroup]绑定使用这个新的控制器模型更新页面。

下面是完整的响应式表单的组件文件,与两个模板驱动组件文件对比:

  1. import { Component, OnInit } from '@angular/core';
  2. import { FormGroup, FormBuilder, Validators } from '@angular/forms';
  3. import { Hero } from '../shared/hero';
  4. import { forbiddenNameValidator } from '../shared/forbidden-name.directive';
  5. @Component({
  6. selector: 'hero-form-reactive3',
  7. templateUrl: './hero-form-reactive.component.html'
  8. })
  9. export class HeroFormReactiveComponent implements OnInit {
  10. powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
  11. hero = new Hero(18, 'Dr. WhatIsHisName', this.powers[0], 'Dr. What');
  12. submitted = false;
  13. onSubmit() {
  14. this.submitted = true;
  15. this.hero = this.heroForm.value;
  16. }
  17. }
  18. heroForm: FormGroup;
  19. constructor(private fb: FormBuilder) { }
  20. ngOnInit(): void {
  21. this.buildForm();
  22. }
  23. buildForm(): void {
  24. this.heroForm = this.fb.group({
  25. 'name': [this.hero.name, [
  26. Validators.required,
  27. Validators.minLength(4),
  28. Validators.maxLength(24),
  29. forbiddenNameValidator(/bob/i)
  30. ]
  31. ],
  32. 'alterEgo': [this.hero.alterEgo],
  33. 'power': [this.hero.power, Validators.required]
  34. });
  35. this.heroForm.valueChanges
  36. .subscribe(data => this.onValueChanged(data));
  37. this.onValueChanged(); // (re)set validation messages now
  38. }
  39. onValueChanged(data?: any) {
  40. if (!this.heroForm) { return; }
  41. const form = this.heroForm;
  42. for (const field in this.formErrors) {
  43. // clear previous error message (if any)
  44. this.formErrors[field] = '';
  45. const control = form.get(field);
  46. if (control && control.dirty && !control.valid) {
  47. const messages = this.validationMessages[field];
  48. for (const key in control.errors) {
  49. this.formErrors[field] += messages[key] + ' ';
  50. }
  51. }
  52. }
  53. }
  54. formErrors = {
  55. 'name': '',
  56. 'power': ''
  57. };
  58. validationMessages = {
  59. 'name': {
  60. 'required': 'Name is required.',
  61. 'minlength': 'Name must be at least 4 characters long.',
  62. 'maxlength': 'Name cannot be more than 24 characters long.',
  63. 'forbiddenName': 'Someone named "Bob" cannot be a hero.'
  64. },
  65. 'power': {
  66. 'required': 'Power is required.'
  67. }
  68. };
  69. }

运行在线例子,查看响应式表单是的行为,并与本章中的例子文件作比较。

自定义验证

本烹饪书例子有一个自定义forbiddenNameValidator函数,在模板驱动和响应式表单中都有使用。 它在app/shared目录,在SharedModule中被声明。

下面是forbiddenNameValidator函数:

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 name = control.value;
    const no = nameRe.test(name);
    return no ? {'forbiddenName': {name}} : null;
  };
}

该函数其实是一个工厂函数,接受一个正则表达式,用来检测指定的禁止的名字,并返回验证器函数。

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

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

Custom validation directive

自定义验证指令

在响应式表单组件中,我们在'name'控制器的验证函数列表的底部添加了一个配置了的forbiddenNameValidator

reactive/hero-form-reactive.component.ts (name validators)

'name': [this.hero.name, [
    Validators.required,
    Validators.minLength(4),
    Validators.maxLength(24),
    forbiddenNameValidator(/bob/i)
  ]
],

在模板驱动组件的模板中,我们在name的输入框元素中添加了自定义属性指令的选择器(forbiddenName),并配置它来拒绝“bob”。

template/hero-form-template2.component.html (name input)

<input type="text" id="name" class="form-control"
       required minlength="4" maxlength="24" forbiddenName="bob"
       name="name" [(ngModel)]="hero.name" >

对应的ForbiddenValidatorDirective包装了forbiddenNamevalidator

Angular表单接受指令在验证流程中的作用,因为指令注册自己到NG_VALIDATORS提供商中,该提供商拥有可扩展的验证指令集。

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

providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]

指令的其它部分是为了帮你理解它们是如何合作的:

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

  1. @Directive({
  2. selector: '[forbiddenName]',
  3. providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]
  4. })
  5. export class ForbiddenValidatorDirective implements Validator, OnChanges {
  6. @Input() forbiddenName: string;
  7. private valFn = Validators.nullValidator;
  8. ngOnChanges(changes: SimpleChanges): void {
  9. const change = changes['forbiddenName'];
  10. if (change) {
  11. const val: string | RegExp = change.currentValue;
  12. const re = val instanceof RegExp ? val : new RegExp(val, 'i');
  13. this.valFn = forbiddenNameValidator(re);
  14. } else {
  15. this.valFn = Validators.nullValidator;
  16. }
  17. }
  18. validate(control: AbstractControl): {[key: string]: any} {
  19. return this.valFn(control);
  20. }
  21. }

If you are familiar with Angular validations, you may have noticed that the custom validation directive is instantiated with useExisting rather than useClass. The registered validator must be this instance of the ForbiddenValidatorDirective—the instance in the form with its forbiddenName property bound to “bob". If you were to replace useExisting with useClass, then you’d be registering a new class instance, one that doesn’t have a forbiddenName.

To see this in action, run the example and then type “bob” in the name of Hero Form 2. Notice that you get a validation error. Now change from useExisting to useClass and try again. This time, when you type “bob”, there's no "bob" error message.

参见属性型指令章节。

测试时的注意事项

我们可以为响应式表单的验证器和控制器逻辑编写孤立单元测试

孤立单元测试直接检测组件类,与组件和它的模板的交互、DOM、其他以来和Angular本省都无关。

这样的测试具有简单设置#,快速编写和容易维护的特征。它们不需要Angular TestBed或异步测试工序。

这对模板驱动表单来说是不可能的。 模板驱动方法依靠Angular来生成控制器模型并从HTML验证属性中衍生验证规则。 你必须使用Angular TestBed来创建组件测试实例,编写异步测试并与DOM交互。

虽然这种测试并不困难,但是它需要更多时间、工作和能力 - 这些因素往往会降低测试代码覆盖率和测试质量。