當我們打算自定義表單控件前,我們應該先考慮一下以下問題:
是否已經有相同語義的 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-Driven
或 Reactive
表單中使用該組件,具體如下:
<!-- 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 - 用於
text
和textarea
類型的輸入控件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 Reference 和 Angular 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;
}
另外我們應該在檢測到 counterRangeMin
和 counterRangeMax
輸入屬性時,就需要調用 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
});
}
}
以上代碼成功運行後,瀏覽器頁面的顯示結果如下: