原文連接: Never again be confused when implementing ControlValueAccessor in Angular forms
若是你正在作一個複雜項目,必然會須要自定義表單控件,這個控件主要須要實現 ControlValueAccessor
接口(譯者注:該接口定義方法可參考 API 文檔說明,也可參考 Angular 源碼定義)。網上有大量文章描述如何實現這個接口,但不多說到它在 Angular 表單架構裏扮演什麼角色,若是你不只僅想知道如何實現,還想知道爲何這樣實現,那本文正合你的胃口。css
首先我解釋下爲啥須要 ControlValueAccessor
接口以及它在 Angular 中是如何使用的。而後我將展現如何封裝第三方組件做爲 Angular 組件,以及如何使用輸入輸出機制實現組件間通訊(譯者注:Angular 組件間通訊輸入輸出機制可參考官網文檔),最後將展現如何使用 ControlValueAccessor
來實現一種針對 Angular 表單新的數據通訊機制。html
若是你以前使用過 Angular 表單,你可能會熟悉 FormControl ,Angular 官方文檔將它描述爲追蹤單個表單控件值和有效性的實體對象。須要明白,無論你使用模板驅動仍是響應式表單(譯者注:即模型驅動),FormControl
都總會被建立。若是你使用響應式表單,你須要顯式建立 FormControl
對象,並使用 formControl
或 formControlName
指令來綁定原生控件;若是你使用模板驅動方法,FormControl
對象會被 NgModel
指令隱式建立(譯者注:可查看 Angular 源碼這一行):react
@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
。jquery
原生表單控件數量是有限的,可是自定義表單控件是無限的,因此 Angular 須要一種通用機制來橋接原生/自定義表單控件和 formControl
指令,而這正是 ControlValueAccessor
乾的事情。這個對象橋接原生表單控件和 formControl
指令,並同步二者的值。官方文檔是這麼描述的(譯者注:爲清晰理解,該描述不翻譯):git
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 源碼這一行):github
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)。api
下圖是 Angular 表單控件
如何經過 ControlValueAccessor
來和原生表單控件
交互的(譯者注:formControl
和你寫的或者 Angular 提供的 CustomControlValueAccessor
兩個都是要綁定到 native DOM element 的指令,而 formControl
指令須要藉助 CustomControlValueAccessor
指令/組件,來和 native DOM element 交換數據。):架構
再次強調,不論是使用響應式表單顯式建立仍是使用模板驅動表單隱式建立,ControlValueAccessor
都老是和 Angular 表單控件進行交互。app
Angular 也爲全部原生 DOM 表單元素建立了 Angular
表單控件(譯者注:Angular 內置的 ControlValueAccessor):ide
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 倉庫。