1. 程式人生 > >angular自定義表單控制元件(轉)

angular自定義表單控制元件(轉)

當我們打算自定義表單控制元件前,我們應該先考慮一下以下問題:

  • 是否已經有相同語義的 native (本機) 元素?如:<input type="number">

  • 如果有,我們就應該考慮能否依賴該元素,僅使用 CSS 或漸進增強的方式來改變其外觀/行為就能滿足我們的需求?

  • 如果沒有,自定義控制元件會是什麼樣的?

  • 我們如何讓它可以訪問 (accessible)?

  • 在不同平臺上自定義控制元件的行為是否有所不同?

  • 自定義控制元件如何實現資料驗證功能?

可能還有很多事情需要考慮,但如果我們決定使用 Angular 建立自定義控制元件,就需要考慮以下問題:

  • 如何實現 model -> view 的資料繫結?

  • 如何實現 view -> model 的資料同步?

  • 若需要自定義驗證,應該如何實現?

  • 如何向DOM元素新增有效性狀態,便於設定不同樣式?

  • 如何讓控制元件可以訪問 (accessible)?

  • 該控制元件能應用於 template-driven 表單?

  • 該控制元件能應用於 model-driven 表單?

(備註:主要瀏覽器上 HTML 5 當前輔助功能支援狀態,可以參看 - HTML5 Accessibility)

Creating a custom counter

現在我們從最簡單的 Counter 元件開始,具體程式碼如下:

counter.component.ts

import { Component, Input } from
'@angular/core'; @Component({ selector: 'exe-counter', template: ` <div> <p>當前值: {{ count }}</p> <button (click)="increment()"> + </button> <button (click)="decrement()"> - </button> </div> ` }) export class CounterComponent { @Input
() count: number = 0; increment() { this.count++; } decrement() { this.count--; } }

app.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <exe-counter></exe-counter>
  `,
})
export class AppComponent { }

app.module.ts

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

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

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

很好,CounterComponent 元件很快就實現了。但現在我們想在 Template-DrivenReactive 表單中使用該元件,具體如下:

<!-- this doesn't work YET -->
<form #form="ngForm">
  <exe-counter name="counter" ngModel></exe-counter>
  <button type="submit">Submit</button>
</form>

現在我們還不能直接這麼使用,要實現該功能。我們要先搞清楚 ControlValueAccessor,因為它是表單模型和DOM 元素之間的橋樑。

Understanding ControlValueAccessor

當我們執行上面示例時,瀏覽器控制檯中將輸出以下異常資訊:

Uncaught (in promise): Error: No value accessor for form control with name: 'counter'

那麼,ControlValueAccessor 是什麼?那麼你們還記得我們之前提到的實現自定義控制元件需要確認的事情麼?其中一個要確認的事情就是,要實現 Model -> View,View -> Model 之間的資料繫結,而這就是我們 ControlValueAccessor 要處理的問題。

ControlValueAccessor 是一個介面,它的作用是:

  • 把 form 模型中值對映到檢視中

  • 當檢視發生變化時,通知 form directives 或 form controls

Angular 引入這個介面的原因是,不同的輸入控制元件資料更新方式是不一樣的。例如,對於我們常用的文字輸入框來說,我們是設定它的 value 值,而對於複選框 (checkbox) 我們是設定它的 checked 屬性。實際上,不同型別的輸入控制元件都有一個 ControlValueAccessor,用來更新檢視。

Angular 中常見的 ControlValueAccessor 有:

  • DefaultValueAccessor - 用於 texttextarea 型別的輸入控制元件

  • SelectControlValueAccessor - 用於 select 選擇控制元件

  • CheckboxControlValueAccessor - 用於 checkbox 複選控制元件

接下來我們的 CounterComponent 元件需要實現 ControlValueAccessor 介面,這樣我們才能更新元件中 count 的值,並通知外界該值已發生改變。

Implementing ControlValueAccessor

首先我們先看一下 ControlValueAccessor 介面,具體如下:

// angular2/packages/forms/src/directives/control_value_accessor.ts 
export interface ControlValueAccessor {
  writeValue(obj: any): void;
  registerOnChange(fn: any): void;
  registerOnTouched(fn: any): void;
  setDisabledState?(isDisabled: boolean): void;
}
  • writeValue(obj: any):該方法用於將模型中的新值寫入檢視或 DOM 屬性中。

  • registerOnChange(fn: any):設定當控制元件接收到 change 事件後,呼叫的函式

  • registerOnTouched(fn: any):設定當控制元件接收到 touched 事件後,呼叫的函式

  • setDisabledState?(isDisabled: boolean):當控制元件狀態變成 DISABLED 或從 DISABLED 狀態變化成 ENABLE 狀態時,會呼叫該函式。該函式會根據引數值,啟用或禁用指定的 DOM 元素。

接下來我們先來實現 writeValue() 方法:

@Component(...)
class CounterComponent implements ControlValueAccessor {
  ...
  writeValue(value: any) {
    this.counterValue = value;
  }
}

當表單初始化的時候,將會使用表單模型中對應的初始值作為引數,呼叫 writeValue() 方法。這意味著,它會覆蓋預設值0,一切看來都沒問題。但我們回想一下在表單中 CounterComponent 元件預期的使用方式:

<form #form="ngForm">
  <exe-counter name="counter" ngModel></exe-counter>
  <button type="submit">Submit</button>
</form>

你會發現,我們沒有為 CounterComponent 元件設定初始值,因此我們要調整一下 writeValue() 中的程式碼,具體如下:

writeValue(value: any) {
  if (value) {
    this.count = value;
  }
}

現在,只有當合法值 (非 undefined、null、"") 寫入控制元件時,它才會覆蓋預設值。接下來,我們來實現 registerOnChange()registerOnTouched() 方法。registerOnChange() 可以用來通知外部,元件已經發生變化。registerOnChange() 方法接收一個 fn 引數,用於設定當控制元件接收到 change 事件後,呼叫的函式。而對於 registerOnTouched() 方法,它也支援一個 fn 引數,用於設定當控制元件接收到 touched 事件後,呼叫的函式。示例中我們不打算處理 touched 事件,因此 registerOnTouched() 我們設定為一個空函式。具體如下:

@Component(...)
class CounterComponent implements ControlValueAccessor {
  ...
  propagateChange = (_: any) => {};

  registerOnChange(fn: any) {
    this.propagateChange = fn;
  }

  registerOnTouched(fn: any) {}
}

很好,我們的 CounterComponent 元件已經實現了ControlValueAccessor 介面。接下來我們需要做的是在每次count 的值改變時,需要呼叫 propagateChange() 方法。換句話說,當用戶點選了 +- 按鈕時,我們希望將新值傳遞到外部。

@Component(...)
export class CounterComponent implements ControlValueAccessor {
    ...
    increment() {
        this.count++;
        this.propagateChange(this.count);
    }

    decrement() {
        this.count--;
        this.propagateChange(this.count);
    }
}

是不是感覺上面程式碼有點冗餘,接下來我們來利用屬性修改器,重構一下以上程式碼,具體如下:

counter.component.ts

import { Component, Input } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';

@Component({
    selector: 'exe-counter',
    template: `
      <p>當前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    `
})
export class CounterComponent implements ControlValueAccessor {
    @Input() _count: number = 0;

    get count() {
        return this._count;
    }

    set count(value: number) {
        this._count = value;
        this.propagateChange(this._count);
    }

    propagateChange = (_: any) => { };

    writeValue(value: any) {
        if (value !== undefined) {
            this.count = value;
        }
    }

    registerOnChange(fn: any) {
        this.propagateChange = fn;
    }

    registerOnTouched(fn: any) { }

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}

CounterComponent 元件已經基本開發好了,但要能正常使用的話,還需要執行註冊操作。

Registering the ControlValueAccessor

對於我們開發的 CounterComponent 元件來說,實現 ControlValueAccessor 介面只完成了一半工作。要讓 Angular 能夠正常識別我們自定義的 ControlValueAccessor,我們還需要執行註冊操作。具體方式如下:

  • 步驟一:建立 EXE_COUNTER_VALUE_ACCESSOR

import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

export const EXE_COUNTER_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};
  • 步驟二:設定元件的 providers 資訊

@Component({
    selector: 'exe-counter',
    ...
    providers: [EXE_COUNTER_VALUE_ACCESSOR]
})

萬事俱備只欠東風,我們馬上進入實戰環節,實際檢驗一下我們開發的 CounterComponent 元件。完整程式碼如下:

counter.component.ts

import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

export const EXE_COUNTER_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>當前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `,
    providers: [EXE_COUNTER_VALUE_ACCESSOR]
})
export class CounterComponent implements ControlValueAccessor {
    @Input() _count: number = 0;

    get count() {
        return this._count;
    }

    set count(value: number) {
        this._count = value;
        this.propagateChange(this._count);
    }

    propagateChange = (_: any) => { };

    writeValue(value: any) {
        if (value) {
            this.count = value;
        }
    }

    registerOnChange(fn: any) {
        this.propagateChange = fn;
    }

    registerOnTouched(fn: any) { }

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}

Using it inside template-driven forms

Angular 4.x 中有兩種表單:

  • Template-Driven Forms - 模板驅動式表單 (類似於 Angular 1.x 中的表單 )

  • Reactive Forms - 響應式表單

瞭解 Angular 4.x Template-Driven Forms 詳細資訊,請參考 - Angular 4.x Template-Driven Forms。接下來我們來看一下具體如何使用:

1.匯入 FormsModule 模組

app.module.ts

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

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

2.更新 AppComponent

2.1 未設定 CounterComponent 元件初始值

app.component.ts

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <form #form="ngForm">
      <exe-counter name="counter" ngModel></exe-counter>
    </form>
    <pre>{{ form.value | json }}</pre>
  `,
})
export class AppComponent { }

友情提示:上面示例程式碼中,form.value 用於獲取表單中的值,json 是 Angular 內建管道,用於執行物件序列化操作 (內部實現 - JSON.stringify(value, null, 2))。若想了解 Angular 管道詳細資訊,請參考 - Angular 2 Pipe

2.2 設定 CounterComponent 元件初始值 - 使用 [ngModel] 語法

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <form #form="ngForm">
      <exe-counter name="counter" [ngModel]="outerCounterValue"></exe-counter>
    </form>
    <pre>{{ form.value | json }}</pre>
  `,
})
export class AppComponent { 
  outerCounterValue: number = 5;  
}

2.3 設定資料雙向繫結 - 使用 [(ngModel)] 語法

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'exe-app',
  template: `
    <form #form="ngForm">
      <p>outerCounterValue value: {{outerCounterValue}}</p>
      <exe-counter name="counter" [(ngModel)]="outerCounterValue"></exe-counter>
    </form>
    <pre>{{ form.value | json }}</pre>
  `,
})
export class AppComponent { 
  outerCounterValue: number = 5;  
}

Using it inside reactive forms

瞭解 Angular 4.x Reactive (Model-Driven) Forms 詳細資訊,請參考 - Angular 4.x Reactive Forms。接下來我們來看一下具體如何使用:

1.匯入 ReactiveFormsModule

app.module.ts

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

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

2.更新 AppComponent

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

@Component({
  selector: 'exe-app',
  template: `
    <form [formGroup]="form">
      <exe-counter formControlName="counter"></exe-counter>
    </form>
    <pre>{{ form.value | json }}</pre>
  `,
})
export class AppComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      counter: 5 // 設定初始值
    });
  }
}

友情提示:上面程式碼中我們移除了 Template-Driven 表單中的 ngModel 和 name 屬性,取而代之是使用 formControlName 屬性。此外我們通過 FormBuilder 物件提供的 group() 方法,建立 FromGroup 物件,然後在模板中通過 [formGroup]="form" 的方式實現模型與 DOM 元素的繫結。關於 Reactive Forms 的詳細資訊,請參考 Angular 4.x Reactive Forms

最後我們在來看一下,如何為我們的自定義控制元件,新增驗證規則。

Adding custom validation

Angular 4.x 基於AbstractControl自定義表單驗證 這篇文章中,我們介紹瞭如何自定義表單驗證。而對於我們自定義控制元件來說,新增自定義驗證功能 (限制控制元件值的有效範圍:0 <= value <=10),也很方便。具體示例如下:

1.自定義 VALIDATOR

1.1 定義驗證函式

export const validateCounterRange: ValidatorFn = (control: AbstractControl): 
  ValidationErrors => {
    return (control.value > 10 || control.value < 0) ?
        { 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
};

1.2 註冊自定義驗證器

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useValue: validateCounterRange,
    multi: true
};

2.更新 AppComponent

接下來我們更新一下 AppComponent 元件,在元件模板中顯示異常資訊:

@Component({
  selector: 'exe-app',
  template: `
    <form [formGroup]="form">
      <exe-counter formControlName="counter"></exe-counter>
    </form>
    <p *ngIf="!form.valid">Counter is invalid!</p>
    <pre>{{ form.get('counter').errors | json }}</pre>
  `,
})

CounterComponent 元件的完整程式碼如下:

counter.component.ts

import { Component, Input, forwardRef } from '@angular/core';
import {
    ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS,
    AbstractControl, ValidatorFn, ValidationErrors, FormControl
} from '@angular/forms';

export const EXE_COUNTER_VALUE_ACCESSOR: any = {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

export const validateCounterRange: ValidatorFn = (control: AbstractControl): 
  ValidationErrors => {
    return (control.value > 10 || control.value < 0) ?
        { 'rangeError': { current: control.value, max: 10, min: 0 } } : null;
};

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useValue: validateCounterRange,
    multi: true
};

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>當前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `,
    providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
export class CounterComponent implements ControlValueAccessor {
    @Input() _count: number = 0;

    get count() {
        return this._count;
    }

    set count(value: number) {
        this._count = value;
        this.propagateChange(this._count);
    }

    propagateChange = (_: any) => { };

    writeValue(value: any) {
        if (value) {
            this.count = value;
        }
    }

    registerOnChange(fn: any) {
        this.propagateChange = fn;
    }

    registerOnTouched(fn: any) { }

    increment() {
        this.count++;
    }

    decrement() {
        this.count--;
    }
}

除了在 CounterComponent 元件的 Metadata 配置自定義驗證器之外,我們也可以在建立 FormGroup 物件時,設定每個控制元件 (FormControl) 物件的驗證規則。需調整的程式碼如下:

counter.component.ts

@Component({
    selector: 'exe-counter',
    ...,
    providers: [EXE_COUNTER_VALUE_ACCESSOR] // 移除自定義EXE_COUNTER_VALIDATOR
})

app.component.ts

import { validateCounterRange } from './couter.component';
...

export class AppComponent {
  ...
  ngOnInit() {
    this.form = this.fb.group({
      counter: [5, validateCounterRange] // 設定validateCounterRange驗證器
    });
  }
}

自定義驗證功能我們已經實現了,但驗證規則即資料的有效範圍是固定 (0 <= value <=10),實際上更好的方式是讓使用者能夠靈活地配置資料的有效範圍。接下來我們就來優化一下現有的功能,使得我們開發的元件更為靈活。

Making the validation configurable

我們自定義 CounterComponent 元件的預期使用方式如下:

<exe-counter
  formControlName="counter"
  counterRangeMax="10"
  counterRangeMin="0">
</exe-counter>

首先我們需要更新一下 CounterComponent 元件,增量 counterRangeMax 和 counterRangeMin 輸入屬性:

@Component(...)
class CounterInputComponent implements ControlValueAccessor {
  ...
  @Input() counterRangeMin: number;

  @Input() counterRangeMax: number;
  ...
}

接著我們需要新增一個 createCounterRangeValidator() 工廠函式,用於根據設定的最大值 (maxValue) 和最小值 (minValue) 動態的建立 validateCounterRange() 函式。具體示例如下:

export function createCounterRangeValidator(maxValue: number, minValue: number) {
    return (control: AbstractControl): ValidationErrors => {
        return (control.value > +maxValue || control.value < +minValue) ?
          { 'rangeError': { current: control.value, max: maxValue, 
               min: minValue }} : null;
    }
}

Angular 4.x 自定義驗證指令 文章中,我們介紹瞭如何自定義驗證指令。要實現指令的自定義驗證功能,我們需要實現 Validator 介面:

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

另外我們應該在檢測到 counterRangeMincounterRangeMax 輸入屬性時,就需要呼叫 createCounterRangeValidator() 方法,動態建立 validateCounterRange() 函式,然後在 validate() 方法中呼叫驗證函式,並返回函式呼叫後的返回值。是不是有點繞,我們馬上看一下具體程式碼:

import { Component, Input, OnChanges, SimpleChanges, forwardRef } from '@angular/core';
import {
    ControlValueAccessor, NG_VALUE_ACCESSOR, NG_VALIDATORS, Validator,
    AbstractControl, ValidatorFn, ValidationErrors, FormControl
} from '@angular/forms';

...

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

export function createCounterRangeValidator(maxValue: number, minValue: number) {
    return (control: AbstractControl): ValidationErrors => {
        return (control.value > +maxValue || control.value < +minValue) ?
            { 'rangeError': { current: control.value, max: maxValue, min: minValue } } 
              : null;
    }
}

@Component({
    selector: 'exe-counter',
    template: `
    <div>
      <p>當前值: {{ count }}</p>
      <button (click)="increment()"> + </button>
      <button (click)="decrement()"> - </button>
    </div>
    `,
    providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})
export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    private _validator: ValidatorFn;
    private _onChange: () => void;

    @Input() counterRangeMin: number; // 設定資料有效範圍的最大值

    @Input() counterRangeMax: number; // 設定資料有效範圍的最小值

    // 監聽輸入屬性變化,呼叫內部的_createValidator()方法,建立RangeValidator
    ngOnChanges(changes: SimpleChanges): void {
        if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
            this._createValidator();
        }
    }

    // 動態建立RangeValidator
    private _createValidator(): void {
        this._validator = createCounterRangeValidator(this.counterRangeMax,
           this.counterRangeMin);
    }

    // 執行控制元件驗證
    validate(c: AbstractControl): ValidationErrors | null {
        return this.counterRangeMin == null || this.counterRangeMax == null ? 
            null : this._validator(c);
    }
      
  ...
}

上面的程式碼很長,我們來分解一下:

註冊 Validator

export const EXE_COUNTER_VALIDATOR = {
    provide: NG_VALIDATORS,
    useExisting: forwardRef(() => CounterComponent),
    multi: true
};

@Component({
    selector: 'exe-counter',
    ...,
    providers: [EXE_COUNTER_VALUE_ACCESSOR, EXE_COUNTER_VALIDATOR]
})

建立 createCounterRangeValidator() 工廠函式

export function createCounterRangeValidator(maxValue: number, minValue: number) {
    return (control: AbstractControl): ValidationErrors => {
        return (control.value > +maxValue || control.value < +minValue) ?
            { 'rangeError': { current: control.value, max: maxValue, min: minValue } } 
              : null;
    }
}

實現 OnChanges 介面,監聽輸入屬性變化建立RangeValidator

export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    @Input() counterRangeMin: number; // 設定資料有效範圍的最大值
    @Input() counterRangeMax: number; // 設定資料有效範圍的最小值
    
    // 監聽輸入屬性變化,呼叫內部的_createValidator()方法,建立RangeValidator
    ngOnChanges(changes: SimpleChanges): void {
        if ('counterRangeMin' in changes || 'counterRangeMax' in changes) {
            this._createValidator();
        }
    }
  ...
}

呼叫 _createValidator() 方法建立RangeValidator

export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    // 動態建立RangeValidator
    private _createValidator(): void {
        this._validator = createCounterRangeValidator(this.counterRangeMax,
           this.counterRangeMin);
    }
  ...
}

實現 Validator 介面,實現控制元件驗證功能

export class CounterComponent implements ControlValueAccessor, Validator,
    OnChanges {
    ...
    // 執行控制元件驗證
    validate(c: AbstractControl): ValidationErrors | null {
        return this.counterRangeMin == null || this.counterRangeMax == null ? 
            null : this._validator(c);
    }
   ...
}

此時我們自定義 CounterComponent 元件終於開發完成了,就差功能驗證了。具體的使用示例如下:

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

@Component({
  selector: 'exe-app',
  template: `
    <form [formGroup]="form">
      <exe-counter formControlName="counter" 
        counterRangeMin="5" 
        counterRangeMax="8">
      </exe-counter>
    </form>
    <p *ngIf="!form.valid">Counter is invalid!</p>
    <pre>{{ form.get('counter').errors | json }}</pre>
  `,
})
export class AppComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) { }

  ngOnInit() {
    this.form = this.fb.group({
      counter: 5
    });
  }
}

以上程式碼成功執行後,瀏覽器頁面的顯示結果如下:

圖片描述

參考資源