原文連結:Never again be confused when implementing ControlValueAccessor in Angular forms
如果你正在做一個複雜專案,必然會需要自定義表單控制元件,這個控制元件主要需要實現 ControlValueAccessor
介面(譯者注:該介面定義方法可參考 API 文件說明,也可參考 Angular 原始碼定義)。網上有大量文章描述如何實現這個介面,但很少說到它在 Angular 表單架構裡扮演什麼角色,如果你不僅僅想知道如何實現,還想知道為什麼這樣實現,那本文正合你的胃口。
首先我解釋下為啥需要 ControlValueAccessor
介面以及它在 Angular 中是如何使用的。然後我將展示如何封裝第三方元件作為 Angular 元件,以及如何使用輸入輸出機制實現元件間通訊(譯者注:Angular 元件間通訊輸入輸出機制可參考 官網文件),最後將展示如何使用 ControlValueAccessor
來實現一種針對 Angular 表單新的資料通訊機制。
FormControl 和 ControlValueAccessor
如果你之前使用過 Angular 表單,你可能會熟悉 FormControl ,Angular 官方文件將它描述為追蹤單個表單控制元件值和有效性的實體物件。需要明白,不管你使用模板驅動還是響應式表單(譯者注:即模型驅動),FormControl
都總會被建立。如果你使用響應式表單,你需要顯式建立 FormControl
物件,並使用 formControl
或 formControlName
指令來繫結原生控制元件;如果你使用模板驅動方法,FormControl
物件會被 NgModel
指令隱式建立(譯者注:可檢視 Angular 原始碼 這一行):
@Directive({
selector: '[ngModel]...',
...
})
export class NgModel ... {
_control = new FormControl(); <---------------- here
複製程式碼
不管 formControl
是隱式還是顯式建立,都必須和原生 DOM 表單控制元件如 input,textarea
進行互動,並且很有可能需要自定義一個表單控制元件作為 Angular 元件而不是使用原生表單控制元件,而通常自定義表單控制元件會封裝一個使用純 JS 寫的控制元件如 jQuery UI's Slider
。本文我將使用原生表單控制元件術語來區分 Angular 特定的 formControl
和你在 html
使用的表單控制元件,但你需要知道任何一個自定義表單控制元件都可以和 formControl
指令進行互動,而不是原生表單控制元件如 input
。
原生表單控制元件數量是有限的,但是自定義表單控制元件是無限的,所以 Angular 需要一種通用機制來橋接原生/自定義表單控制元件和 formControl
指令,而這正是 ControlValueAccessor
乾的事情。這個物件橋接原生表單控制元件和 formControl
指令,並同步兩者的值。官方文件是這麼描述的(譯者注:為清晰理解,該描述不翻譯):
A ControlValueAccessor acts as a bridge between the Angular forms API and a native element in the DOM.
任何一個元件或指令都可以通過實現 ControlValueAccessor
介面並註冊為 NG_VALUE_ACCESSOR
,從而轉變成 ControlValueAccessor
型別的物件,稍後我們將一起看看如何做。另外,這個介面還定義兩個重要方法——writeValue
和 registerOnChange
(譯者注:可檢視 Angular 原始碼 這一行):
interface ControlValueAccessor {
writeValue(obj: any): void
registerOnChange(fn: any): void
registerOnTouched(fn: any): void
...
}
複製程式碼
formControl
指令使用 writeValue
方法設定原生表單控制元件的值(譯者注:你可能會參考 L186 和 L41);使用 registerOnChange
方法來註冊由每次原生表單控制元件值更新時觸發的回撥函式(譯者注:你可能會參考這三行,L186 和 L43,以及 L85),你需要把更新的值傳給這個回撥函式,這樣對應的 Angular 表單控制元件值也會更新(譯者注:這一點可以參考 Angular 它自己寫的 DefaultValueAccessor
的寫法是如何把 input 控制元件每次更新值傳給回撥函式的,L52 和 L89);使用 registerOnTouched
方法來註冊使用者和控制元件互動時觸發的回撥(譯者注:你可能會參考 L95)。
下圖是 Angular 表單控制元件
如何通過 ControlValueAccessor
來和原生表單控制元件
互動的(譯者注:formControl
和你寫的或者 Angular 提供的 CustomControlValueAccessor
兩個都是要繫結到 native DOM element 的指令,而 formControl
指令需要藉助 CustomControlValueAccessor
指令/元件,來和 native DOM element 交換資料。):
再次強調,不管是使用響應式表單顯式建立還是使用模板驅動表單隱式建立,ControlValueAccessor
都總是和 Angular 表單控制元件進行互動。
Angular 也為所有原生 DOM 表單元素建立了 Angular
表單控制元件(譯者注:Angular 內建的 ControlValueAccessor):
Accessor | Form Element |
---|---|
DefaultValueAccessor | input,textarea |
CheckboxControlValueAccessor | input[type=checkbox] |
NumberValueAccessor | input[type=number] |
RadioControlValueAccessor | input[type=radio] |
RangeValueAccessor | input[type=range] |
SelectControlValueAccessor | select |
SelectMultipleControlValueAccessor | select[multiple] |
從上表中可看到,當 Angular 在元件模板中中遇到 input
或 textarea
DOM 原生控制元件時,會使用DefaultValueAccessor
指令:
@Component({
selector: 'my-app',
template: `
<input [formControl]="ctrl">
`
})
export class AppComponent {
ctrl = new FormControl(3);
}
複製程式碼
所有表單指令,包括上面程式碼中的 formControl
指令,都會呼叫 setUpControl 函式來讓表單控制元件和DefaultValueAccessor
實現互動(譯者注:意思就是上面程式碼中繫結的 formControl
指令,在其自身例項化時,會呼叫 setUpControl()
函式給同樣繫結到 input
的 DefaultValueAccessor
指令做好安裝工作,如 L85,這樣 formControl
指令就可以藉助 DefaultValueAccessor
來和 input
元素交換資料了)。細節可參考 formControl
指令的程式碼:
export class FormControlDirective ... {
...
ngOnChanges(changes: SimpleChanges): void {
if (this._isControlChanged(changes)) {
setUpControl(this.form, this);
複製程式碼
還有 setUpControl
函式原始碼也指出了原生表單控制元件和 Angular 表單控制元件是如何資料同步的(譯者注:作者貼的可能是 Angular v4.x 的程式碼,v5 有了點小小變動,但基本相似):
export function setUpControl(control: FormControl, dir: NgControl) {
// initialize a form control
// 呼叫 writeValue() 初始化表單控制元件值
dir.valueAccessor.writeValue(control.value);
// setup a listener for changes on the native control
// and set this value to form control
// 設定原生控制元件值更新時監聽器,每當原生控制元件值更新,Angular 表單控制元件值也更新
valueAccessor.registerOnChange((newValue: any) => {
control.setValue(newValue, {emitModelToViewChange: false});
});
// setup a listener for changes on the Angular formControl
// and set this value to the native control
// 設定 Angular 表單控制元件值更新監聽器,每當 Angular 表單控制元件值更新,原生控制元件值也更新
control.registerOnChange((newValue: any, ...) => {
dir.valueAccessor.writeValue(newValue);
});
複製程式碼
只要我們理解了內部機制,就可以實現我們自定義的 Angular 表單控制元件了。
元件封裝器
由於 Angular 為所有預設原生控制元件提供了控制元件值訪問器,所以在封裝第三方外掛或元件時,需要寫一個新的控制元件值訪問器。我們將使用上文提到的 jQuery UI 庫的 slider 外掛,來實現一個自定義表單控制元件吧。
簡單的封裝器
最基礎實現是通過簡單封裝使其能在螢幕上顯示出來,所以我們需要一個 NgxJquerySliderComponent
元件,並在其模板裡渲染出 slider
:
@Component({
selector: 'ngx-jquery-slider',
template: `
<div #location></div>
`,
styles: ['div {width: 100px}']
})
export class NgxJquerySliderComponent {
@ViewChild('location') location;
widget;
ngOnInit() {
this.widget = $(this.location.nativeElement).slider();
}
}
複製程式碼
這裡我們使用標準的 jQuery
方法在原生 DOM 元素上建立一個 slider
控制元件,然後使用 widget
屬性引用這個控制元件。
一旦簡單封裝好了 slider
元件,我們就可以在父元件模板裡使用它:
@Component({
selector: 'my-app',
template: `
<h1>Hello {{name}}</h1>
<ngx-jquery-slider></ngx-jquery-slider>
`
})
export class AppComponent { ... }
複製程式碼
為了執行程式我們需要加入 jQuery
相關依賴,簡化起見,在 index.html
中新增全域性依賴:
<script src="https://code.jquery.com/jquery-3.2.1.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<link rel="stylesheet" href="//code.jquery.com/ui/1.12.1/themes/smoothness/jquery-ui.css">
複製程式碼
這裡是安裝依賴的 原始碼。
互動式表單控制元件
上面的實現還不能讓我們自定義的 slider
控制元件與父元件互動,所以還得使用輸入/輸出繫結來是實現元件間資料通訊:
export class NgxJquerySliderComponent {
@ViewChild('location') location;
@Input() value;
@Output() private valueChange = new EventEmitter();
widget;
ngOnInit() {
this.widget = $(this.location.nativeElement).slider();
this.widget.slider('value', this.value);
this.widget.on('slidestop', (event, ui) => {
this.valueChange.emit(ui.value);
});
}
ngOnChanges() {
if (this.widget && this.widget.slider('value') !== this.value) {
this.widget.slider('value', this.value);
}
}
}
複製程式碼
一旦 slider
元件建立,就可以訂閱 slidestop
事件獲取變化的值,一旦 slidestop
事件被觸發了,就可以使用輸出事件發射器 valueChanges
通知父元件。當然我們也可以使用 ngOnChanges
生命週期鉤子來追蹤輸入屬性 value
值的變化,一旦其值變化,我們就將該值設定為 slider
控制元件的值。
然後就是父元件中如何使用 slider
元件的程式碼實現:
<ngx-jquery-slider
[value]="sliderValue"
(valueChange)="onSliderValueChange($event)">
</ngx-jquery-slider>
複製程式碼
原始碼 在這裡。
但是,我們想要的是,使用 slider
元件作為表單的一部分,並使用模板驅動表單或響應式表單的指令與其資料通訊,那就需要讓其實現 ControlValueAccessor
介面了。由於我們將實現的是新的元件通訊方式,所以不需要標準的輸入輸出屬性繫結方式,那就移除相關程式碼吧。(譯者注:作者先實現標準的輸入輸出屬性繫結的通訊方式,又要刪除,主要是為了引入新的表單元件互動方式,即 ControlValueAccessor
。)
實現自定義控制元件值訪問器
實現自定義控制元件值訪問器並不難,只需要兩步:
- 註冊
NG_VALUE_ACCESSOR
提供者 - 實現
ControlValueAccessor
介面
NG_VALUE_ACCESSOR
提供者用來指定實現了 ControlValueAccessor
介面的類,並且被 Angular 用來和 formControl
同步,通常是使用元件類或指令來註冊。所有表單指令都是使用NG_VALUE_ACCESSOR
標識來注入控制元件值訪問器,然後選擇合適的訪問器(譯者注:這句話可參考這兩行程式碼,L175 和 L181)。要麼選擇DefaultValueAccessor
或者內建的資料訪問器,否則 Angular 將會選擇自定義的資料訪問器,並且有且只有一個自定義的資料訪問器(譯者注:這句話參考 selectValueAccessor
原始碼實現)。
讓我們首先定義提供者:
@Component({
selector: 'ngx-jquery-slider',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: NgxJquerySliderComponent,
multi: true
}]
...
})
class NgxJquerySliderComponent implements ControlValueAccessor {...}
複製程式碼
我們直接在元件裝飾器裡直接指定類名,然而 Angular 原始碼預設實現是放在類裝飾器外面:
export const DEFAULT_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DefaultValueAccessor),
multi: true
};
@Directive({
selector:'input',
providers: [DEFAULT_VALUE_ACCESSOR]
...
})
export class DefaultValueAccessor implements ControlValueAccessor {}
複製程式碼
放在外面就需要使用 forwardRef
,關於原因可以參考 What is forwardRef in Angular and why we need it 。當實現自定義 controlValueAccessor
,我建議還是放在類裝飾器裡吧(譯者注:個人建議還是學習 Angular 原始碼那樣放在外面)。
一旦定義了提供者後,就讓我們實現 controlValueAccessor
介面:
export class NgxJquerySliderComponent implements ControlValueAccessor {
@ViewChild('location') location;
widget;
onChange;
value;
ngOnInit() {
this.widget = $(this.location.nativeElement).slider(this.value);
this.widget.on('slidestop', (event, ui) => {
this.onChange(ui.value);
});
}
writeValue(value) {
this.value = value;
if (this.widget && value) {
this.widget.slider('value', value);
}
}
registerOnChange(fn) { this.onChange = fn; }
registerOnTouched(fn) { }
複製程式碼
由於我們對使用者是否與元件互動不感興趣,所以先把 registerOnTouched
置空吧。在registerOnChange
裡我們簡單儲存了對回撥函式 fn
的引用,回撥函式是由 formControl
指令傳入的(譯者注:參考 L85),只要每次 slider
元件值發生改變,就會觸發這個回撥函式。在 writeValue
方法內我們把得到的值傳給 slider
元件。
現在我們把上面描述的功能做成一張互動式圖:
如果你把簡單封裝和 controlValueAccessor
封裝進行比較,你會發現父子元件互動方式是不一樣的,儘管封裝的元件與 slider
元件的互動是一樣的。你可能注意到 formControl
指令實際上簡化了與父元件互動的方式。這裡我們使用 writeValue
來向子元件寫入資料,而在簡單封裝方法中使用 ngOnChanges
;呼叫 this.onChange
方法輸出資料,而在簡單封裝方法中使用 this.valueChange.emit(ui.value)
。
現在,實現了 ControlValueAccessor
介面的自定義 slider
表單控制元件完整程式碼如下:
@Component({
selector: 'my-app',
template: `
<h1>Hello {{name}}</h1>
<span>Current slider value: {{ctrl.value}}</span>
<ngx-jquery-slider [formControl]="ctrl"></ngx-jquery-slider>
<input [value]="ctrl.value" (change)="updateSlider($event)">
`
})
export class AppComponent {
ctrl = new FormControl(11);
updateSlider($event) {
this.ctrl.setValue($event.currentTarget.value, {emitModelToViewChange: true});
}
}
複製程式碼
你可以檢視程式的 最終實現。
Github
專案的 Github 倉庫。