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

lx1036發表於2018-03-29

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

easy-control-value-accessor

如果你正在做一個複雜專案,必然會需要自定義表單控制元件,這個控制元件主要需要實現 ControlValueAccessor 介面(譯者注:該介面定義方法可參考 API 文件說明,也可參考 Angular 原始碼定義)。網上有大量文章描述如何實現這個介面,但很少說到它在 Angular 表單架構裡扮演什麼角色,如果你不僅僅想知道如何實現,還想知道為什麼這樣實現,那本文正合你的胃口。

首先我解釋下為啥需要 ControlValueAccessor 介面以及它在 Angular 中是如何使用的。然後我將展示如何封裝第三方元件作為 Angular 元件,以及如何使用輸入輸出機制實現元件間通訊(譯者注:Angular 元件間通訊輸入輸出機制可參考 官網文件),最後將展示如何使用 ControlValueAccessor 來實現一種針對 Angular 表單新的資料通訊機制。

FormControl 和 ControlValueAccessor

如果你之前使用過 Angular 表單,你可能會熟悉 FormControl ,Angular 官方文件將它描述為追蹤單個表單控制元件值和有效性的實體物件。需要明白,不管你使用模板驅動還是響應式表單(譯者注:即模型驅動),FormControl 都總會被建立。如果你使用響應式表單,你需要顯式建立 FormControl 物件,並使用 formControlformControlName 指令來繫結原生控制元件;如果你使用模板驅動方法,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 型別的物件,稍後我們將一起看看如何做。另外,這個介面還定義兩個重要方法——writeValueregisterOnChange (譯者注:可檢視 Angular 原始碼 這一行):

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)。

下圖是 Angular 表單控制元件 如何通過 ControlValueAccessor 來和原生表單控制元件互動的(譯者注:formControl你寫的或者 Angular 提供的 CustomControlValueAccessor 兩個都是要繫結到 native DOM element 的指令,而 formControl 指令需要藉助 CustomControlValueAccessor 指令/元件,來和 native DOM element 交換資料。):

angular_form_control-controlValueAccessor-native_form_control

再次強調,不管是使用響應式表單顯式建立還是使用模板驅動表單隱式建立,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 在元件模板中中遇到 inputtextarea DOM 原生控制元件時,會使用DefaultValueAccessor 指令:

@Component({
  selector: 'my-app',
  template: `
      <input [formControl]="ctrl">
  `
})
export class AppComponent {
  ctrl = new FormControl(3);
}
複製程式碼

所有表單指令,包括上面程式碼中的 formControl 指令,都會呼叫 setUpControl 函式來讓表單控制元件和DefaultValueAccessor 實現互動(譯者注:意思就是上面程式碼中繫結的 formControl 指令,在其自身例項化時,會呼叫 setUpControl() 函式給同樣繫結到 inputDefaultValueAccessor 指令做好安裝工作,如 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 倉庫

相關文章