# 验证表单输入

通过验证用户输入的准确性和完整性,可以提高整体的数据质量。该页面显示了如何从 UI 验证用户输入,以及如何在响应式表单和模板驱动表单中显示有用的验证消息。

# 前提条件

在阅读表单验证之前,你应该对这些内容有一个基本的了解。

  • TypeScript和 HTML5 编程
  • Angular 应用设计的基本概念
  • Angular 支持的两类表单
  • 模板驱动表单或响应式表单的基础知识

要获取这里用讲解表单验证的响应式表单和模板驱动表单的完整范例代码。请运行现场演练/ 下载范例。

# 在模板驱动表单中验证输入

为了往模板驱动表单中添加验证机制,你要添加一些验证属性,就像原生的 HTML 表单验证器一样。 Angular 会用指令来匹配这些具有验证功能的指令。

每当表单控件中的值发生变化时,Angular 就会进行验证,并生成一个验证错误的列表(对应着 INVALID 状态)或者 null(对应着 VALID 状态)。

你可以通过把 ngModel 导出成局部模板变量来查看该控件的状态。 比如下面这个例子就把 NgModel 导出成了一个名叫 name 的变量:

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

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

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

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

注意这个例子讲解的如下特性。

  • `` 元素带有一些 HTML 验证属性:requiredminlength。它还带有一个自定义的验证器指令 forbiddenName。欲知详情,参阅自定义验证器一节。
  • #name="ngModel"NgModel 导出成了一个名叫 name 的局部变量。NgModel 把自己控制的 FormControl 实例的属性映射出去,让你能在模板中检查控件的状态,比如 validdirty。要了解完整的控件属性,参阅 API 参考手册中的AbstractControl。
  • &lt;div&gt; 元素的 *ngIf 展示了一组嵌套的消息 div,但是只在有“name”错误和控制器为 dirty 或者 touched 时才出现。
  • 每个嵌套的 `` 为其中一个可能出现的验证错误显示一条自定义消息。比如 requiredminlengthforbiddenName

为防止验证程序在用户有机会编辑表单之前就显示错误,你应该检查控件的 dirty 状态或 touched 状态。

  • 当用户在被监视的字段中修改该值时,控件就会被标记为 dirty(脏)
  • 当用户的表单控件失去焦点时,该控件就会被标记为 touched(已接触)

# 在响应式表单中验证输入

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

# 验证器(Validator)函数

验证器函数可以是同步函数,也可以是异步函数。

验证器类型 详细信息
同步验证器 这些同步函数接受一个控件实例,然后返回一组验证错误或 null。可以在实例化一个 FormControl 时把它作为构造函数的第二个参数传进去。
异步验证器 这些异步函数接受一个控件实例并返回一个 Promise 或 Observable,它稍后会发出一组验证错误或 null。在实例化 FormControl 时,可以把它们作为第三个参数传入。

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

# 内置验证器函数

你可以选择编写自己的验证器函数,也可以使用 Angular 的一些内置验证器。

在模板驱动表单中用作属性的那些内置验证器,比如 requiredminlength,也都可以作为 Validators 类中的函数使用。关于内置验证器的完整列表,参阅 API 参考手册中的验证器部分。

要想把这个英雄表单改造成一个响应式表单,还是要用那些内置验证器,但这次改为用它们的函数形态。参阅下面的例子。

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.requiredValidators.minLength(4) 以及一个自定义验证器 forbiddenNameValidator。(欲知详情,参阅下面的自定义验证器部分。)

所有这些验证器都是同步的,所以它们作为第二个参数传递。注意,你可以通过把这些函数放到一个数组中传入来支持多个验证器。

这个例子还添加了一些 getter 方法。在响应式表单中,你通常会通过它所属的控件组(FormGroup)的 get 方法来访问表单控件,但有时候为模板定义一些 getter 作为简短形式。

如果你到模板中找到 name 输入框,就会发现它和模板驱动的例子很相似。

reactive/hero-form-reactive.component.html (name with error msg)

<input type="text" 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>

这个表单与模板驱动的版本不同,它不再导出任何指令。相反,它使用组件类中定义的 name 读取器(getter)。

请注意,required 属性仍然出现在模板中。虽然它对于验证来说不是必须的,但为了无障碍性,还是应该保留它。

# 定义自定义验证器

内置的验证器并不是总能精确匹配应用中的用例,因此有时你需要创建一个自定义验证器。

考虑前面的响应式式表单中的 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): ValidationErrors | null => {
    const forbidden = nameRe.test(control.value);
    return forbidden ? {forbiddenName: {value: control.value}} : null;
  };
}

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

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

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

自定义异步验证器和同步验证器很像,只是它们必须返回一个稍后会输出 null 或“验证错误对象”的承诺(Promise)或可观察对象,如果是可观察对象,那么它必须在某个时间点被完成(complete),那时候这个表单就会使用它输出的最后一个值作为验证结果。(译注:HTTP 服务是自动完成的,但是某些自定义的可观察对象可能需要手动调用 complete 方法)

# 把自定义验证器添加到响应式表单中

在响应式表单中,通过直接把该函数传给 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)
});

# 为模板驱动表单中添加自定义验证器

在模板驱动表单中,要为模板添加一个指令,该指令包含了 validator 函数。比如,对应的 ForbiddenValidatorDirective 用作 forbiddenNameValidator 的包装器。

Angular 在验证过程中会识别出该指令的作用,因为该指令把自己注册成了 NG_VALIDATORS 提供者,如下例所示。NG_VALIDATORS 是一个带有可扩展验证器集合的预定义提供者。

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

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

然后该指令类实现了 Validator 接口,以便它能简单的与 Angular 表单集成在一起。这个指令的其余部分有助于你理解它们是如何协作的:

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

@Directive({
  selector: '[appForbiddenName]',
  providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]
})
export class ForbiddenValidatorDirective implements Validator {
  @Input('appForbiddenName') forbiddenName = '';

  validate(control: AbstractControl): ValidationErrors | null {
    return this.forbiddenName ? forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control)
                              : null;
  }
}

一旦 ForbiddenValidatorDirective 写好了,你只要把 forbiddenName 选择器添加到输入框上就可以激活这个验证器了。比如:

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

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

注意,自定义验证指令是用 useExisting 而不是 useClass 来实例化的。注册的验证程序必须是 ForbiddenValidatorDirective 实例本身 - 表单中的实例,也就是表单中 forbiddenName 属性被绑定到了"bob"的那个。

如果用 useClass 来代替 useExisting,就会注册一个新的类实例,而它是没有 forbiddenName 的。

# 表示控件状态的 CSS 类

Angular 会自动把很多控件属性作为 CSS 类映射到控件所在的元素上。你可以使用这些类来根据表单状态给表单控件元素添加样式。目前支持下列类:

  • .ng-valid
  • .ng-invalid
  • .ng-pending
  • .ng-pristine
  • .ng-dirty
  • .ng-untouched
  • .ng-touched
  • .ng-submitted (只对 form 元素添加)

在下面的例子中,这个英雄表单使用 .ng-valid.ng-invalid 来设置每个表单控件的边框颜色。

forms.css (status classes)

.ng-valid[required], .ng-valid.required  {
  border-left: 5px solid #42A948; /* green */
}

.ng-invalid:not(form)  {
  border-left: 5px solid #a94442; /* red */
}

.alert div {
  background-color: #fed3d3;
  color: #820000;
  padding: 1rem;
  margin-bottom: 1rem;
}

.form-group {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: .5rem;
}

select {
  width: 100%;
  padding: .5rem;
}

# 跨字段交叉验证

跨字段交叉验证器是一种自定义验证器,可以对表单中不同字段的值进行比较,并针对它们的组合进行接受或拒绝。比如,你可能有一个提供互不兼容选项的表单,以便让用户选择 A 或 B,而不能两者都选。某些字段值也可能依赖于其它值;用户可能只有当选择了 A 之后才能选择 B。

下列交叉验证的例子说明了如何进行如下操作:

  • 根据两个兄弟控件的值验证响应式表单或模板驱动表单的输入,
  • 当用户与表单交互过,且验证失败后,就会显示描述性的错误信息。

这些例子使用了交叉验证,以确保英雄们不会通过填写 Hero 表单来暴露自己的真实身份。验证器会通过检查英雄的名字和第二人格是否匹配来做到这一点。

# 为响应式表单添加交叉验证

该表单具有以下结构:

const heroForm = new FormGroup({
  'name': new FormControl(),
  'alterEgo': new FormControl(),
  'power': new FormControl()
});

注意,namealterEgo 是兄弟控件。要想在单个自定义验证器中计算这两个控件,你就必须在它们共同的祖先控件中执行验证:FormGroup。你可以在 FormGroup 中查询它的子控件,从而让你能比较它们的值。

要想给 FormGroup 添加验证器,就要在创建时把一个新的验证器传给它的第二个参数。

const heroForm = new FormGroup({
  'name': new FormControl(),
  'alterEgo': new FormControl(),
  'power': new FormControl()
}, { validators: identityRevealedValidator });

验证器的代码如下。

shared/identity-revealed.directive.ts

/** A hero's name can't match the hero's alter ego */
export const identityRevealedValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
  const name = control.get('name');
  const alterEgo = control.get('alterEgo');

  return name && alterEgo && name.value === alterEgo.value ? { identityRevealed: true } : null;
};

这个 identity 验证器实现了 ValidatorFn 接口。它接收一个 Angular 表单控件对象作为参数,当表单有效时,它返回一个 null,否则返回 ValidationErrors 对象。

该验证器通过调用 FormGroup 的 get方法来检索这些子控件,然后比较 namealterEgo 控件的值。

如果值不匹配,则 hero 的身份保持秘密,两者都有效,且 validator 返回 null。如果匹配,就说明英雄的身份已经暴露了,验证器必须通过返回一个错误对象来把这个表单标记为无效的。

为了提供更好的用户体验,当表单无效时,模板还会显示一条恰当的错误信息。

reactive/hero-form-template.component.html

<div *ngIf="heroForm.errors?.['identityRevealed'] && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">
    Name cannot match alter ego.
</div>

如果 FormGroup 中有一个由 identityRevealed 验证器返回的交叉验证错误,*ngIf 就会显示错误,但只有当该用户已经与表单进行过交互的时候才显示。

# 为模板驱动表单添加交叉验证

对于模板驱动表单,你必须创建一个指令来包装验证器函数。你可以使用NG_VALIDATORS 令牌来把该指令提供为验证器,如下例所示。

shared/identity-revealed.directive.ts

@Directive({
  selector: '[appIdentityRevealed]',
  providers: [{ provide: NG_VALIDATORS, useExisting: IdentityRevealedValidatorDirective, multi: true }]
})
export class IdentityRevealedValidatorDirective implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    return identityRevealedValidator(control);
  }
}

你必须把这个新指令添加到 HTML 模板中。由于验证器必须注册在表单的最高层,因此下列模板会把该指令放在 form 标签上。

template/hero-form-template.component.html

<form #heroForm="ngForm" appIdentityRevealed>

为了提供更好的用户体验,当表单无效时,我们要显示一个恰当的错误信息。

template/hero-form-template.component.html

<div *ngIf="heroForm.errors?.['identityRevealed'] && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert">
    Name cannot match alter ego.
</div>

这在模板驱动表单和响应式表单中都是一样的。

# 创建异步验证器

异步验证器实现了 AsyncValidatorFnAsyncValidator 接口。它们与其同步版本非常相似,但有以下不同之处。

  • validate() 函数必须返回一个 Promise 或可观察对象,
  • 返回的可观察对象必须是有尽的,这意味着它必须在某个时刻完成(complete)。要把无尽的可观察对象转换成有尽的,可以在管道中加入过滤操作符,比如 firstlasttaketakeUntil

异步验证在同步验证完成后才会发生,并且只有在同步验证成功时才会执行。如果更基本的验证方法已经发现了无效输入,那么这种检查顺序就可以让表单避免使用昂贵的异步验证流程(比如 HTTP 请求)。

异步验证开始之后,表单控件就会进入 pending 状态。可以检查控件的 pending 属性,并用它来给出对验证中的视觉反馈。

一种常见的 UI 模式是在执行异步验证时显示 Spinner(转轮)。下面的例子展示了如何在模板驱动表单中实现这一点。

<input [(ngModel)]="name" #model="ngModel" appSomeAsyncValidator>
<app-spinner *ngIf="model.pending"></app-spinner>

# 实现自定义异步验证器

在下面的例子中,异步验证器可以确保英雄们选择了一个尚未采用的第二人格。新英雄不断涌现,老英雄也会离开,所以无法提前找到可用的人格列表。为了验证潜在的第二人格条目,验证器必须启动一个异步操作来查询包含所有在编英雄的中央数据库。

下面的代码创建了一个验证器类 UniqueAlterEgoValidator,它实现了 AsyncValidator 接口。

@Injectable({ providedIn: 'root' })
export class UniqueAlterEgoValidator implements AsyncValidator {
  constructor(private heroesService: HeroesService) {}

  validate(
    control: AbstractControl
  ): Observable<ValidationErrors | null> {
    return this.heroesService.isAlterEgoTaken(control.value).pipe(
      map(isTaken => (isTaken ? { uniqueAlterEgo: true } : null)),
      catchError(() => of(null))
    );
  }
}

构造函数中注入了 HeroesService,它定义了如下接口。

interface HeroesService {
  isAlterEgoTaken: (alterEgo: string) => Observable<boolean>;
}

在真实的应用中,HeroesService 会负责向英雄数据库发起一个 HTTP 请求,以检查该第二人格是否可用。 从该验证器的视角看,此服务的具体实现无关紧要,所以这个例子仅仅针对 HeroesService 接口来写实现代码。

当验证开始的时候,UniqueAlterEgoValidator 把任务委托给 HeroesServiceisAlterEgoTaken() 方法,并传入当前控件的值。这时候,该控件会被标记为 pending 状态,直到 validate() 方法所返回的可观察对象完成(complete)了。

isAlterEgoTaken() 方法会调度一个 HTTP 请求来检查第二人格是否可用,并返回 Observable 作为结果。validate() 方法通过 map 操作符来对响应对象进行管道化处理,并把它转换成验证结果。

与任何验证器一样,如果表单有效,该方法返回 null,如果无效,则返回 ValidationErrors。这个验证器使用 catchError 操作符来处理任何潜在的错误。在这个例子中,验证器将 isAlterEgoTaken() 错误视为成功的验证,因为未能发出验证请求并不一定意味着这个第二人格无效。你也可以用不同的方式处理这种错误,比如返回 ValidationError 对象。

一段时间过后,这条可观察对象链完成,异步验证也就完成了。pending 标志位也设置为 false,该表单的有效性也已更新。

# 将异步验证器添加到响应式表单

要以响应式表单使用异步验证器,请首先将验证器注入组件类的构造函数。

constructor(private alterEgoValidator: UniqueAlterEgoValidator) {}

然后,将验证器函数直接传递给 FormControl 以应用它。

在以下示例中,UniqueAlterEgoValidatorvalidate 函数将其传递给控件的 asyncValidators 选项并将其绑定到注入到 HeroFormReactiveComponent 中的 UniqueAlterEgoValidator 实例,最终将其应用于 alterEgoControlasyncValidators 的值可以是单个异步验证器函数,也可以是函数数组。要了解有关 FormControl 选项的更多信息,参阅AbstractControlOptionsAPI 参考。

const alterEgoControl = new FormControl('', {
  asyncValidators: [this.alterEgoValidator.validate.bind(this.alterEgoValidator)],
  updateOn: 'blur'
});

# 将异步验证器添加到模板驱动表单

要在模板驱动表单中使用异步验证器,请创建一个新指令并在其上注册 NG_ASYNC_VALIDATORS 提供者。

在下面的示例中,该指令注入包含实际验证逻辑的 UniqueAlterEgoValidator 类,并在应该进行验证时由 Angular 触发的 validate 函数中调用它。

@Directive({
  selector: '[appUniqueAlterEgo]',
  providers: [
    {
      provide: NG_ASYNC_VALIDATORS,
      useExisting: forwardRef(() => UniqueAlterEgoValidatorDirective),
      multi: true
    }
  ]
})
export class UniqueAlterEgoValidatorDirective implements AsyncValidator {
  constructor(private validator: UniqueAlterEgoValidator) {}

  validate(
    control: AbstractControl
  ): Observable<ValidationErrors | null> {
    return this.validator.validate(control);
  }
}

然后,与使用同步验证器一样,将指令的选择器添加到输入以激活它。

template/hero-form-template.component.html (unique-alter-ego-input)content_copy

# 优化异步验证器的性能

默认情况下,所有验证程序在每次表单值更改后都会运行。对于同步验证器,这通常不会对应用性能产生明显的影响。但是,异步验证器通常会执行某种 HTTP 请求来验证控件。每次按键后调度一次 HTTP 请求都会给后端 API 带来压力,应该尽可能避免。

你可以把 updateOn 属性从 change(默认值)改成 submitblur 来推迟表单验证的更新时机。

使用模板驱动表单时,可以在模板中设置该属性。

<input [(ngModel)]="name" [ngModelOptions]="{updateOn: 'blur'}">

使用响应式表单时,可以在 FormControl 实例中设置该属性。

new FormControl('', {updateOn: 'blur'});

# 与原生 HTML 表单验证器交互

默认情况下,Angular 通过在 `` 元素上添加 novalidate 属性来禁用原生 HTML 表单验证,并使用指令将这些属性与框架中的验证器函数相匹配。如果你想将原生验证与基于 Angular 的验证结合使用,你可以使用 ngNativeValidate 指令来重新启用它。欲知详情,参阅 API 文档。

Last Updated: 5/13/2023, 10:57:08 AM