[譯] 別再對 Angular 表單的 ControlValueAccessor 感到迷惑

原文連接: Never again be confused when implementing ControlValueAccessor in Angular forms

ceasy-control-value-accessor

若是你正在作一個複雜項目,必然會須要自定義表單控件,這個控件主要須要實現 ControlValueAccessor 接口(譯者注:該接口定義方法可參考 API 文檔說明,也可參考 Angular 源碼定義)。網上有大量文章描述如何實現這個接口,但不多說到它在 Angular 表單架構裏扮演什麼角色,若是你不只僅想知道如何實現,還想知道爲何這樣實現,那本文正合你的胃口。css

首先我解釋下爲啥須要 ControlValueAccessor 接口以及它在 Angular 中是如何使用的。而後我將展現如何封裝第三方組件做爲 Angular 組件,以及如何使用輸入輸出機制實現組件間通訊(譯者注:Angular 組件間通訊輸入輸出機制可參考官網文檔),最後將展現如何使用 ControlValueAccessor 來實現一種針對 Angular 表單新的數據通訊機制。html

FormControl 和 ControlValueAccessor

若是你以前使用過 Angular 表單,你可能會熟悉 FormControl ,Angular 官方文檔將它描述爲追蹤單個表單控件值和有效性的實體對象。須要明白,無論你使用模板驅動仍是響應式表單(譯者注:即模型驅動),FormControl 都總會被建立。若是你使用響應式表單,你須要顯式建立 FormControl 對象,並使用 formControlformControlName 指令來綁定原生控件;若是你使用模板驅動方法,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 指令進行交互,而不是原生表單控件如 inputjquery

原生表單控件數量是有限的,可是自定義表單控件是無限的,因此 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 類型的對象,稍後咱們將一塊兒看看如何作。另外,這個接口還定義兩個重要方法——writeValueregisterOnChange (譯者注:可查看 Angular 源碼這一行):github

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  ...
}

formControl 指令使用 writeValue 方法設置原生表單控件的值(譯者注:你可能會參考 L186L41);使用 registerOnChange 方法來註冊由每次原生表單控件值更新時觸發的回調函數(譯者注:你可能會參考這三行,L186L43,以及 L85),你須要把更新的值傳給這個回調函數,這樣對應的 Angular 表單控件值也會更新(譯者注:這一點能夠參考 Angular 它本身寫的 DefaultValueAccessor 的寫法是如何把 input 控件每次更新值傳給回調函數的,L52L89);使用 registerOnTouched 方法來註冊用戶和控件交互時觸發的回調(譯者注:你可能會參考 L95)。api

下圖是 Angular 表單控件 如何經過 ControlValueAccessor 來和原生表單控件交互的(譯者注:formControl你寫的或者 Angular 提供的 CustomControlValueAccessor 兩個都是要綁定到 native DOM element 的指令,而 formControl 指令須要藉助 CustomControlValueAccessor 指令/組件,來和 native DOM element 交換數據。):架構

angular_form_control-controlValueAccessor-native_form_control

再次強調,不論是使用響應式表單顯式建立仍是使用模板驅動表單隱式建立,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 在組件模板中中遇到 inputtextarea 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。)

實現自定義控件值訪問器

實現自定義控件值訪問器並不難,只須要兩步:

  1. 註冊 NG_VALUE_ACCESSOR 提供者
  2. 實現 ControlValueAccessor 接口

NG_VALUE_ACCESSOR 提供者用來指定實現了 ControlValueAccessor 接口的類,而且被 Angular 用來和 formControl 同步,一般是使用組件類或指令來註冊。全部表單指令都是使用NG_VALUE_ACCESSOR 標識來注入控件值訪問器,而後選擇合適的訪問器(譯者注:這句話可參考這兩行代碼,L175L181)。要麼選擇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 組件。

如今咱們把上面描述的功能作成一張交互式圖:

jQuery_slider-slider_component-form_control

若是你把簡單封裝和 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 倉庫

相關文章
相關標籤/搜索