Angular 4.x 自定義驗證指令

semlinker發表於2017-04-13

表單是幾乎每個 Web 應用程式的一部分。雖然 Angular 為我們提供了幾個內建 validators (驗證器),但在實際工作中為了滿足專案需求,我們經常需要為應用新增一些自定義驗證功能。接下來我們將著重介紹,如何自定義 validator 指令。

Built-in Validators

Angular 提供了一些內建的 validators,我們可以在 Template-DrivenReactive 表單中使用它們。如果你對 Template-Driven 和 Reactive 表單還不瞭解的話,可以參考 Angular 4.x 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 效果圖如下:

Angular 4.x 自定義驗證指令

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

  • 步驟一

Angular 4.x 自定義驗證指令

  • 步驟二

Angular 4.x 自定義驗證指令

看起來一切很順利,但請繼續看下圖:

Angular 4.x 自定義驗證指令

什麼情況,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.x 基於AbstractControl自定義表單驗證

參考資源

相關文章