MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Angular表单验证的自定义验证器实现

2024-01-145.8k 阅读

Angular 表单验证基础回顾

在深入探讨自定义验证器之前,我们先来回顾一下 Angular 表单验证的基础知识。Angular 提供了两种主要的表单处理方式:模板驱动表单(Template - Driven Forms)和响应式表单(Reactive Forms)。这两种方式都支持表单验证,不过它们在实现和使用上略有不同。

模板驱动表单验证

模板驱动表单依赖于模板来创建和管理表单。在模板驱动表单中,验证器通常通过 HTML5 的验证属性(如 requiredminlengthmaxlength 等)或 Angular 内置的验证指令(如 ngMinlengthngMaxlength 等)来添加。例如,下面是一个简单的模板驱动表单输入框,并添加了 required 验证:

<form #myForm="ngForm">
  <input type="text" name="username" required>
  <button type="submit" [disabled]="!myForm.form.valid">提交</button>
</form>

在这个例子中,required 属性表明这个输入框是必填的。myForm 是一个本地模板引用变量,它引用了整个表单。通过 myForm.form.valid 可以判断整个表单是否有效,从而控制提交按钮的禁用状态。

响应式表单验证

响应式表单则是通过在组件类中以编程方式创建和管理表单。与模板驱动表单相比,响应式表单提供了更高的灵活性和可测试性。以下是一个使用响应式表单和内置验证器的简单示例:

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

@Component({
  selector: 'app - my - form',
  templateUrl: './my - form.component.html'
})
export class MyFormComponent {
  myForm: FormGroup;

  constructor() {
    this.myForm = new FormGroup({
      username: new FormControl('', [Validators.required, Validators.minLength(3)])
    });
  }
}
<form [formGroup]="myForm">
  <input type="text" formControlName="username">
  <button type="submit" [disabled]="!myForm.valid">提交</button>
</form>

在组件类中,我们创建了一个 FormGroup,其中包含一个 FormControl 名为 username。我们为 username 控件添加了 requiredminLength(3) 两个内置验证器。在模板中,通过 formGroupformControlName 指令将组件类中的表单模型与模板中的输入元素绑定。

自定义验证器的必要性

虽然 Angular 的内置验证器已经能够满足许多常见的验证需求,例如必填字段、最小/最大长度、电子邮件格式等,但在实际项目中,我们经常会遇到一些特殊的验证需求,这些需求无法直接通过内置验证器来实现。

复杂业务规则验证

假设我们正在开发一个电商应用,其中有一个商品价格字段。除了常规的数值验证(如必须为正数)外,业务规则要求当商品属于特定类别时,价格必须在某个特定范围内。这种与业务紧密相关的复杂验证规则,就无法通过内置验证器轻松实现。

跨字段验证

有时候,我们需要根据多个表单字段的值来进行验证。例如,在注册表单中,用户需要输入两次密码以确认密码的一致性。这种验证涉及到两个不同的输入字段,内置验证器无法直接处理。

创建自定义验证器

通用结构

无论是在模板驱动表单还是响应式表单中使用自定义验证器,其核心结构是相似的。自定义验证器本质上是一个函数,该函数接收一个 AbstractControl 参数,并返回一个验证结果对象(如果验证失败)或 null(如果验证成功)。验证结果对象是一个键值对,其中键是验证器的名称,值是一个包含错误信息的对象(通常为空对象)。

import { AbstractControl } from '@angular/forms';

function customValidator(control: AbstractControl): { [key: string]: any } | null {
  // 验证逻辑
  if (/* 验证失败条件 */) {
    return { 'customError': {} };
  }
  return null;
}

模板驱动表单中的自定义验证器

  1. 创建验证器函数:首先,在组件类中定义自定义验证器函数。
import { Component } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, Validator, ValidatorFn } from '@angular/forms';

function passwordStrengthValidator(control: AbstractControl): { [key: string]: any } | null {
  const value = control.value;
  if (!value) {
    return null;
  }

  const hasUpperCase = /[A - Z]/.test(value);
  const hasLowerCase = /[a - z]/.test(value);
  const hasDigit = /\d/.test(value);

  if (!(hasUpperCase && hasLowerCase && hasDigit)) {
    return { 'passwordStrength': {} };
  }

  return null;
}

@Component({
  selector: 'app - template - form',
  templateUrl: './template - form.component.html',
  providers: [
    {
      provide: NG_VALIDATORS,
      useExisting: TemplateFormComponent,
      multi: true
    }
  ]
})
export class TemplateFormComponent implements Validator {
  validate(control: AbstractControl): { [key: string]: any } | null {
    return passwordStrengthValidator(control);
  }
}
  1. 在模板中使用:在模板驱动表单的输入元素上,通过 ngModel 指令的 $validators 属性来添加自定义验证器。
<form #myForm="ngForm">
  <input type="password" name="password" [(ngModel)]="password" [ngModelOptions]="{standalone: true}">
  <div *ngIf="myForm.controls['password']?.hasError('passwordStrength')">
    密码必须包含大写字母、小写字母和数字
  </div>
  <button type="submit" [disabled]="!myForm.form.valid">提交</button>
</form>

响应式表单中的自定义验证器

  1. 创建验证器函数:与模板驱动表单类似,先定义验证器函数。
import { AbstractControl, ValidatorFn } from '@angular/forms';

function emailDomainValidator(domain: string): ValidatorFn {
  return (control: AbstractControl): { [key: string]: any } | null => {
    const value = control.value;
    if (!value) {
      return null;
    }

    const parts = value.split('@');
    if (parts.length!== 2 || parts[1]!== domain) {
      return { 'invalidEmailDomain': { requiredDomain: domain } };
    }

    return null;
  };
}
  1. 在表单控件中使用:在创建 FormControlFormGroup 时,将自定义验证器函数作为参数传递。
import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';

@Component({
  selector: 'app - reactive - form',
  templateUrl: './reactive - form.component.html'
})
export class ReactiveFormComponent {
  myForm: FormGroup;

  constructor() {
    this.myForm = new FormGroup({
      email: new FormControl('', [emailDomainValidator('example.com')])
    });
  }
}
<form [formGroup]="myForm">
  <input type="email" formControlName="email">
  <div *ngIf="myForm.controls['email']?.hasError('invalidEmailDomain')">
    邮箱必须是 example.com 域名
  </div>
  <button type="submit" [disabled]="!myForm.valid">提交</button>
</form>

自定义异步验证器

除了同步验证器,Angular 还支持异步验证器。异步验证器通常用于需要与服务器进行交互或执行一些异步操作的验证场景,例如检查用户名是否已存在于数据库中。

创建异步验证器

异步验证器函数返回一个 ObservablePromise,该 ObservablePromise 最终会解析为验证结果对象(如果验证失败)或 null(如果验证成功)。

import { AbstractControl } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, delay } from 'rxjs/operators';

function asyncUsernameValidator(control: AbstractControl): Observable<{ [key: string]: any } | null> {
  const username = control.value;
  // 模拟异步操作,例如向服务器发送请求
  return of(username).pipe(
    delay(1000),
    map(() => {
      if (username === 'admin') {
        return { 'usernameExists': {} };
      }
      return null;
    })
  );
}

在响应式表单中使用异步验证器

在创建 FormControl 时,将异步验证器函数作为 asyncValidators 参数传递。

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

@Component({
  selector: 'app - async - form',
  templateUrl: './async - form.component.html'
})
export class AsyncFormComponent {
  myForm: FormGroup;

  constructor() {
    this.myForm = new FormGroup({
      username: new FormControl('', null, asyncUsernameValidator)
    });
  }
}
<form [formGroup]="myForm">
  <input type="text" formControlName="username">
  <div *ngIf="myForm.controls['username']?.hasError('usernameExists') && (myForm.controls['username']?.touched || myForm.controls['username']?.dirty)">
    用户名已存在
  </div>
  <button type="submit" [disabled]="!myForm.valid">提交</button>
</form>

注意事项

  1. 性能问题:由于异步验证器会引入延迟,过多的异步验证或长时间的异步操作可能会影响用户体验。因此,要谨慎使用异步验证器,并尽量优化异步操作的性能。
  2. 错误提示时机:在模板中,需要注意设置合适的条件来显示异步验证的错误提示。例如,使用 toucheddirty 标志来确保只有在用户与表单控件交互后才显示错误提示,避免在页面加载时就显示不必要的错误信息。

跨字段自定义验证

如前文所述,跨字段验证是一种特殊的验证需求,需要结合多个表单字段的值进行验证。这种验证在响应式表单中更容易实现。

创建跨字段验证器

假设我们有一个表单,包含两个密码输入字段:passwordconfirmPassword,我们需要验证这两个字段是否一致。

import { AbstractControl } from '@angular/forms';

function passwordMatchValidator(control: AbstractControl): { [key: string]: any } | null {
  const password = control.get('password');
  const confirmPassword = control.get('confirmPassword');

  if (password && confirmPassword && password.value!== confirmPassword.value) {
    return { 'passwordMismatch': {} };
  }

  return null;
}

在响应式表单中使用跨字段验证器

在创建 FormGroup 时,将跨字段验证器函数作为 validators 参数传递。

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

@Component({
  selector: 'app - password - match - form',
  templateUrl: './password - match - form.component.html'
})
export class PasswordMatchFormComponent {
  myForm: FormGroup;

  constructor() {
    this.myForm = new FormGroup({
      password: new FormControl(''),
      confirmPassword: new FormControl('')
    }, { validators: passwordMatchValidator });
  }
}
<form [formGroup]="myForm">
  <input type="password" formControlName="password">
  <input type="password" formControlName="confirmPassword">
  <div *ngIf="myForm.hasError('passwordMismatch') && (myForm.touched || myForm.dirty)">
    两次输入的密码不一致
  </div>
  <button type="submit" [disabled]="!myForm.valid">提交</button>
</form>

自定义验证器的复用

为了提高代码的可维护性和复用性,我们可以将自定义验证器封装成独立的服务或模块。

封装为服务

  1. 创建验证器服务
import { Injectable } from '@angular/core';
import { AbstractControl, ValidatorFn } from '@angular/forms';

@Injectable({
  providedIn: 'root'
})
export class CustomValidatorsService {
  emailDomainValidator(domain: string): ValidatorFn {
    return (control: AbstractControl): { [key: string]: any } | null => {
      const value = control.value;
      if (!value) {
        return null;
      }

      const parts = value.split('@');
      if (parts.length!== 2 || parts[1]!== domain) {
        return { 'invalidEmailDomain': { requiredDomain: domain } };
      }

      return null;
    };
  }
}
  1. 在组件中使用服务
import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { CustomValidatorsService } from './custom - validators.service';

@Component({
  selector: 'app - reusable - form',
  templateUrl: './reusable - form.component.html'
})
export class ReusableFormComponent {
  myForm: FormGroup;

  constructor(private customValidators: CustomValidatorsService) {
    this.myForm = new FormGroup({
      email: new FormControl('', [this.customValidators.emailDomainValidator('example.com')])
    });
  }
}

封装为模块

  1. 创建验证器模块
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CustomValidatorsService } from './custom - validators.service';

@NgModule({
  imports: [
    CommonModule
  ],
  providers: [
    CustomValidatorsService
  ],
  exports: []
})
export class CustomValidatorsModule { }
  1. 在应用模块中导入
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform - browser';
import { AppComponent } from './app.component';
import { CustomValidatorsModule } from './custom - validators.module';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    CustomValidatorsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

通过将自定义验证器封装为服务或模块,可以在多个组件中方便地复用这些验证器,减少重复代码,提高项目的整体质量。

自定义验证器与国际化

在国际化的应用中,我们需要根据不同的语言环境显示不同的验证错误信息。Angular 提供了一些工具和技术来实现这一点。

使用 @angular/localize 进行国际化

  1. 安装和配置:首先,安装 @angular/localize 包,并在 angular.json 文件中配置国际化相关选项。
npm install @angular/localize

angular.json 中添加或修改以下配置:

{
  "architect": {
    "build": {
      "builder": "@angular - localize:browser",
      "options": {
        "localize": ["en", "zh - CN"],
        // 其他现有配置...
      }
    },
    "serve": {
      "builder": "@angular - localize:browser - preview",
      "options": {
        "browserTarget": "your - app - name:build"
      }
    }
  }
}
  1. 在模板中使用:在模板中,使用 i18n 指令来标记需要国际化的验证错误信息。
<form [formGroup]="myForm">
  <input type="email" formControlName="email">
  <div *ngIf="myForm.controls['email']?.hasError('invalidEmailDomain')" i18n>
    邮箱必须是 example.com 域名
  </div>
  <button type="submit" [disabled]="!myForm.valid">提交</button>
</form>
  1. 生成翻译文件:运行以下命令生成翻译文件:
ng xi18n --output - path src/locale

这将在 src/locale 目录下生成 messages.xlf 文件,其中包含了需要翻译的文本。你可以根据不同的语言环境创建相应的翻译文件,如 messages.en.xlfmessages.zh - CN.xlf 等,并进行翻译。

使用自定义管道进行国际化

除了使用 @angular/localize,我们还可以创建自定义管道来实现验证错误信息的国际化。

  1. 创建国际化管道
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'translateError'
})
export class TranslateErrorPipe implements PipeTransform {
  transform(errorKey: string, ...args: any[]): string {
    // 这里可以实现根据语言环境和错误键获取翻译后的错误信息逻辑
    // 例如从一个翻译字典中获取
    const translations = {
      'invalidEmailDomain': '邮箱必须是 example.com 域名',
      'passwordStrength': '密码必须包含大写字母、小写字母和数字'
    };
    return translations[errorKey];
  }
}
  1. 在模板中使用管道
<form [formGroup]="myForm">
  <input type="email" formControlName="email">
  <div *ngIf="myForm.controls['email']?.hasError('invalidEmailDomain')">
    {{ 'invalidEmailDomain' | translateError }}
  </div>
  <button type="submit" [disabled]="!myForm.valid">提交</button>
</form>

通过上述方法,我们可以使自定义验证器更好地适应国际化的应用场景,为不同语言背景的用户提供友好的错误提示。

测试自定义验证器

为了确保自定义验证器的正确性和可靠性,我们需要对其进行单元测试。在 Angular 中,我们可以使用 Jasmine 和 Karma 进行测试。

测试同步验证器

假设我们有一个简单的自定义同步验证器 minValueValidator,用于验证输入值是否大于某个最小值。

  1. 验证器代码
import { AbstractControl, ValidatorFn } from '@angular/forms';

function minValueValidator(min: number): ValidatorFn {
  return (control: AbstractControl): { [key: string]: any } | null => {
    const value = control.value;
    if (!value || typeof value!== 'number' || value >= min) {
      return null;
    }
    return { 'minValue': { requiredValue: min } };
  };
}
  1. 测试代码
import { TestBed } from '@angular/core/testing';
import { AbstractControl, FormControl } from '@angular/forms';
import { minValueValidator } from './min - value.validator';

describe('minValueValidator', () => {
  let control: AbstractControl;

  beforeEach(() => {
    control = new FormControl();
  });

  it('should return null if value is greater than or equal to min', () => {
    control.setValue(5);
    const validator = minValueValidator(3);
    const result = validator(control);
    expect(result).toBeNull();
  });

  it('should return error if value is less than min', () => {
    control.setValue(2);
    const validator = minValueValidator(3);
    const result = validator(control);
    expect(result).toEqual({ 'minValue': { requiredValue: 3 } });
  });

  it('should return null if value is not a number', () => {
    control.setValue('abc');
    const validator = minValueValidator(3);
    const result = validator(control);
    expect(result).toBeNull();
  });

  it('should return null if value is null', () => {
    control.setValue(null);
    const validator = minValueValidator(3);
    const result = validator(control);
    expect(result).toBeNull();
  });
});

测试异步验证器

对于异步验证器,我们需要使用 asyncdone 来处理异步操作。假设我们有一个异步验证器 asyncUsernameAvailableValidator,用于检查用户名是否可用。

  1. 验证器代码
import { AbstractControl } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, delay } from 'rxjs/operators';

function asyncUsernameAvailableValidator(control: AbstractControl): Observable<{ [key: string]: any } | null> {
  const username = control.value;
  return of(username).pipe(
    delay(1000),
    map(() => {
      if (username === 'takenUsername') {
        return { 'usernameNotAvailable': {} };
      }
      return null;
    })
  );
}
  1. 测试代码
import { TestBed } from '@angular/core/testing';
import { AbstractControl, FormControl } from '@angular/forms';
import { asyncUsernameAvailableValidator } from './async - username - available.validator';

describe('asyncUsernameAvailableValidator', () => {
  let control: AbstractControl;

  beforeEach(() => {
    control = new FormControl();
  });

  it('should return null if username is available', (done) => {
    control.setValue('availableUsername');
    asyncUsernameAvailableValidator(control).subscribe(result => {
      expect(result).toBeNull();
      done();
    });
  });

  it('should return error if username is taken', (done) => {
    control.setValue('takenUsername');
    asyncUsernameAvailableValidator(control).subscribe(result => {
      expect(result).toEqual({ 'usernameNotAvailable': {} });
      done();
    });
  });
});

通过编写详细的单元测试,可以确保自定义验证器在各种情况下都能正确工作,提高代码的质量和稳定性。

结语

通过深入了解和实践 Angular 表单验证的自定义验证器,我们能够更好地满足复杂业务需求,提升用户体验。无论是同步验证器、异步验证器、跨字段验证器,还是验证器的复用、国际化以及测试,每个方面都为我们构建健壮、灵活且用户友好的表单提供了有力支持。在实际项目中,合理运用这些技术将有助于打造高质量的前端应用。