Angular 4 自定义验证指令

表单是几乎每个 Web 应用程序的一部分。虽然 Angular 为我们提供了几个内置 validators (验证器),但在实际工作中为了满足项目需求,我们经常需要为应用添加一些自定义验证功能。接下来我们将着重介绍,如何自定义 validator 指令。

Built-in Validators

Angular 提供了一些内置的 validators,我们可以在 Template-DrivenReactive 表单中使用它们。如果你对 Template-Driven 和 Reactive 表单还不了解的话,可以参考 Angular 4 Forms 系列中 Template Driven FormsReactive Forms 这两篇文章。

在写本文时,Angular 支持的内建 validators 如下:

  • required - 设置表单控件值是非空的
  • email - 设置表单控件值的格式是 email
  • minlength - 设置表单控件值的最小长度
  • maxlength - 设置表单控件值的最大长度
  • pattern - 设置表单控件的值需匹配 pattern 对应的模式

在使用内建 validators 之前,我们需要根据使用的表单类型 (Template-Driven 或 Reactive),导入相应的模块,对于 Template-Driven 表单,我们需要导入 FormsModule。具体示例如下:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

@NgModule({
  imports: [BrowserModule, FormsModule], // we add FormsModule here
  declarations: [AppComponent],
  bootstrap: [AppComponent]
})
export class AppModule {}

一旦导入了 FormsModule 模块,我们就可以在应用中使用该模块提供的所有指令:

<form novalidate>
  <input type="text" name="name" ngModel required>
  <input type="text" name="street" ngModel minlength="3">
  <input type="text" name="city" ngModel maxlength="10">
  <input type="text" name="zip" ngModel pattern="[A-Za-z]{5}">
</form>

而对于 Reactive 表单,我们就需要导入 ReactiveFormsModule 模块:

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

@NgModule({
  imports: [BrowserModule, ReactiveFormsModule],
  ...
})
export class AppModule {}

可以直接使用 FormControlFormGroup API 创建表单:

@Component()
class Cmp {

  form: FormGroup;

  ngOnInit() {
    this.form = new FormGroup({
      name: new FormControl('', Validators.required)),
      street: new FormControl('', Validators.minLength(3)),
      city: new FormControl('', Validators.maxLength(10)),
      zip: new FormControl('', Validators.pattern('[A-Za-z]{5}'))
    });
  }
}

也可以利用 FormBuilder 提供的 API,采用更便捷的方式创建表单:

@Component()
class Cmp {

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.form = this.fb.group({
      name: ['', Validators.required],
      street: ['', Validators.minLength(3)],
      city: ['', Validators.maxLength(10)],
      zip: ['', Validators.pattern('[A-Za-z]{5}')]
    });
  }
}

需要注意的是,我们还需要使用 [formGroup] 指令将表单模型与 DOM 中的表单对象关联起来,具体如下:

<form novalidate [formGroup]="form">
  ...
</form>

接下来我们来介绍一下如何自定义 validator 指令。

Building a custom validator directive

在实际开发前,我们先来介绍一下具体需求:我们有一个新增用户的表单页面,里面包含 4 个输入框,分为用于保存用户输入的 usernameemailpasswordconfirmPassword 信息。具体的 UI 效果图如下:

Setup (基础设置)

1.定义 user 接口

export interface User {
    username: string; // 必填,5-8个字符
    email: string; // 必填,有效的email格式
    password: string; // 必填,值要与confirmPassword值一样
    confirmPassword: string; // 必填,值要与password值一样
}

2.导入 ReactiveFormsModule

app.module.ts

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

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

3.初始化 AppComponent

app.component.html

<div>
  <h3>Add User</h3>
  <form novalidate (ngSubmit)="saveUser()" [formGroup]="user">
    <div>
      <label for="">Username</label>
      <input type="text" formControlName="username">
      <div class="error" *ngIf="user.get('username').invalid && 
        user.get('username').touched">
        Username is required (minimum 5 characters, maximum 8 characters).
      </div>
      <!--<pre *ngIf="user.get('username').errors" class="margin-20">
        {{ user.get('username').errors | json }}</pre>-->
    </div>
    <div>
      <label for="">Email</label>
      <input type="email" formControlName="email">
      <div class="error" *ngIf="user.get('email').invalid && user.get('email').touched">
        Email is required and format should be <i>24065****@qq.com</i>.
      </div>
      <!--<pre *ngIf="user.get('email').errors" class="margin-20">
        {{ user.get('email').errors | json }}</pre>-->
    </div>
    <div>
      <label for="">Password</label>
      <input type="password" formControlName="password">
      <div class="error" *ngIf="user.get('password').invalid && 
        user.get('password').touched">
        Password is required
      </div>
      <!--<pre *ngIf="user.get('password').errors" class="margin-20">
        {{ user.get('password').errors | json }}</pre>-->
    </div>
    <div>
      <label for="">Retype password</label>
      <input type="password" formControlName="confirmPassword" validateEqual="password">
      <div class="error" *ngIf="user.get('confirmPassword').invalid && 
        user.get('confirmPassword').touched">
        Password mismatch
      </div>
      <!--<pre *ngIf="user.get('confirmPassword').errors" class="margin-20">
        {{ user.get('confirmPassword').errors | json }}</pre>-->
    </div>
    <button type="submit" class="btn-default" [disabled]="user.invalid">Submit</button>
  </form>
</div>

app.component.ts

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

export interface User {
  username: string; // 必填,5-8个字符
  email: string; // 必填,有效的email格式
  password: string; // 必填,值要与confirmPassword值一样
  confirmPassword: string; // 必填,值要与password值一样
}

@Component({
  moduleId: module.id,
  selector: 'exe-app',
  templateUrl: 'app.component.html',
  styles: [`
    .error {
      border: 1px dashed red;
      color: red;
      padding: 4px;
    }

    .btn-default {
      border: 1px solid;
      background-color: #3845e2;
      color: #fff;
    }

    .btn-default:disabled {
      background-color: #aaa;
    }

  `]
})
export class AppComponent implements OnInit {
  public user: FormGroup;

  constructor(public fb: FormBuilder) { }


  ngOnInit() {
    this.user = this.fb.group({
      username: ['', [Validators.required, Validators.minLength(5), 
                      Validators.maxLength(8)]],
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required]],
      confirmPassword: ['', [Validators.required]]
    });
  }

  saveUser(): void {

  }
}

Custom confirm password validator

接下来我们来实现自定义 equal-validator 指令:

equal-validator.directive.ts

import { Directive, forwardRef, Attribute } from '@angular/core';
import { Validator, AbstractControl, NG_VALIDATORS } from '@angular/forms';

@Directive({
    selector: '[validateEqual][formControlName],[validateEqual][formControl],
               [validateEqual][ngModel]',
    providers: [
        { provide: NG_VALIDATORS, useExisting: forwardRef(() => EqualValidator), 
            multi: true }
    ]
})
export class EqualValidator implements Validator {
    constructor(@Attribute('validateEqual') public validateEqual: string) { }

    validate(c: AbstractControl): { [key: string]: any } {
        // self value (e.g. retype password)
        let v = c.value; // 获取应用该指令,控件上的值

        // control value (e.g. password)
        let e = c.root.get(this.validateEqual); // 获取进行值比对的控件

        // value not equal
        if (e && v !== e.value) 
         return {
            validateEqual: false
         }
        return null;
    }
}

上面的代码很长,我们来分解一下。

Directive declaration

@Directive({
    selector: '[validateEqual][formControlName],[validateEqual] 
        [formControl],[validateEqual][ngModel]',
    providers: [
        { provide: NG_VALIDATORS, useExisting: forwardRef(() => EqualValidator), 
        multi: true }
    ]
})

首先,我们使用 @Directive 装饰器来定义指令。然后我们设置该指令的 Metadata 信息:

  • selector - 定义指令在 HTML 代码中匹配的方式
  • providers - 注册EqualValidator

其中 forwardRef 的作用,请参考 - Angular 4 Forward Reference

Class defintion

export class EqualValidator implements Validator {
    constructor(@Attribute('validateEqual') public validateEqual: string) {}

    validate(c: AbstractControl): { [key: string]: any } {}
}

我们的 EqualValidator 类必须实现 Validator 接口:

export interface Validator {
  validate(c: AbstractControl): ValidationErrors|null;
  registerOnValidatorChange?(fn: () => void): void;
}

该接口要求定义一个 validate() 方法,因此我们的 ``EqualValidator类中就需要实现Validator接口中定义的validate方法。此外在构造函数中,我们通过@Attribute('validateEqual')` 装饰器来获取 validateEqual 属性上设置的值。

Validate implementation

validate(c: AbstractControl): { [key: string]: any } {
    // self value (e.g. retype password)
    let v = c.value; // 获取应用该指令,控件上的值

    // control value (e.g. password)
    let e = c.root.get(this.validateEqual); // 获取进行值比对的控件

    // value not equal
    if (e && v !== e.value) 
     return { // 若不相等,返回验证失败信息
        validateEqual: false
     }
    return null;
}

Use custom validator

要在我们的表单中使用自定义验证器,我们需要将其导入到我们的应用程序模块中。

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';

import { EqualValidator } from './equal-validator.directive';

import { AppComponent } from './app.component';

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

以上代码成功运行后,我们来验证一下刚实现的功能:

友情提示:演示需要先把密码框的类型设置为text

  • 步骤一
  • 步骤二

看起来一切很顺利,但请继续看下图:

什么情况,password 输入框的值已经变成 12345 了,还能验证通过。为什么会出现这个问题呢?因为我们的只在 confirmPassword 输入框中应用 validateEqual 指令。所以 password 输入框的值发生变化时,是不会触发验证的。接下来我们来看一下如何修复这个问题。

Solution

我们将重用我们的 validateEqual 验证器并添加一个 reverse 属性 。

<div>
      <label for="">Password</label>
      <input type="text" formControlName="password" validateEqual="confirmPassword" 
             reverse="true">
      <div class="error" *ngIf="user.get('password').invalid && 
        user.get('password').touched">
        Password is required
      </div>
      <!--<pre *ngIf="user.get('password').errors" class="margin-20">
        {{ user.get('password').errors | json }}</pre>-->
</div>
<div>
      <label for="">Retype password</label>
      <input type="text" formControlName="confirmPassword" validateEqual="password">
      <div class="error" *ngIf="user.get('confirmPassword').invalid && 
        user.get('confirmPassword').touched">
        Password mismatch
      </div>
      <!--<pre *ngIf="user.get('confirmPassword').errors" class="margin-20">
        {{ user.get('confirmPassword').errors | json }}</pre>-->
    </div>
  • 若未设置 reverse 属性或属性值为 false,实现的功能跟前面的一样。
  • reverse 的值设置为 true,我们仍然会执行相同的验证,但错误信息不是添加到当前控件,而是添加到目标控件上。

在上面的示例中,我们设置 password 输入框的 reverse 属性为 true,即 reverse="true"。当 password 输入框的值与 confirmPassword 输入框的值不相等时,我们将把错误信息添加到 confirmPassword 控件上。具体实现如下:

equal-validator.directive.ts

import { Directive, forwardRef, Attribute } from '@angular/core';
import { Validator, AbstractControl, NG_VALIDATORS } from '@angular/forms';

@Directive({
    selector: '[validateEqual][formControlName],[validateEqual][formControl],   
        [validateEqual][ngModel]',
    providers: [
        { provide: NG_VALIDATORS, useExisting: forwardRef(() => EqualValidator), 
            multi: true }
    ]
})
export class EqualValidator implements Validator {
    constructor(@Attribute('validateEqual') public validateEqual: string,
        @Attribute('reverse') public reverse: string) { }

    private get isReverse() {
        if (!this.reverse) return false;
        return this.reverse === 'true';
    }

    validate(c: AbstractControl): { [key: string]: any } {
        // self value
        let v = c.value;

        // control vlaue
        let e = c.root.get(this.validateEqual);

        // value not equal
        // 未设置reverse的值或值为false
        if (e && v !== e.value && !this.isReverse) { 
            return {
                validateEqual: false
            }
        }

        // value equal and reverse
        // 若值相等且reverse的值为true,则删除validateEqual异常信息
        if (e && v === e.value && this.isReverse) { 
            delete e.errors['validateEqual'];
            if (!Object.keys(e.errors).length) e.setErrors(null);
        }

        // value not equal and reverse
        // 若值不相等且reverse的值为true,则把异常信息添加到比对的目标控件上
        if (e && v !== e.value && this.isReverse) { 
            e.setErrors({ validateEqual: false });
        }
        return null;
    }
}

以上代码运行后,成功解决了我们的问题。其实解决该问题还有其它的方案,我们可以基于 passwordconfirmPassword 来创建 FormGroup 对象,然后添加自定义验证来实现上述的功能。详细的信息,请参考 - Angular 4 基于AbstractControl自定义表单验证

参考资源

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容