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

友情提示:想了解 forwardRef 和 multi 的詳細信息,請參考 Angular 2 Forward ReferenceAngular 2 Multi Providers 這兩篇文章。

  • 步驟二:設置組件的 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
    });
  }
}

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

圖片描述

參考資源

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章