@angular/forms 原始碼解析之雙向繫結

lx1036發表於2018-07-20

我們知道,Angular 的 @angular/forms 包提供了 NgModel 指令,來實現雙向繫結,即把一個 JS 變數(假設為 name)與一個 DOM 元素(假設為 input 元素)進行繫結,這樣 name 的值發生變化,input 元素 的 value 也會自動變化;input 元素的 value 發生變化,name 的值也會自動變化。如下程式碼,展示一個最簡單的雙向繫結(也可見 stackblitz demo):

@Component({
  selector: 'my-app',
  template: `
    <input [ngModel]="name" (ngModelChange)="this.name=$event">
    <button (click)="this.name = this.name + ' , apple';">ChangeName</button>
    <p>{{name}}</p>
  `,
  styleUrls: [ './app.component.css' ]
})
export class AppComponent  {
  name = 'banana';
}
複製程式碼

上面程式碼使用了 NgModel 指令來把變數 nameinput DOM 元素雙向繫結到了一起,這裡為了更清晰理解 NgModel 的本質,沒有使用 [(ngModel)] 語法糖。實際上,在模板裡寫 [(xxx)] 這種 'BANANA_BOX' 語法,@angular/compiler 的 Template Parser 會把這種語法拆解為為 [xxx](xxxChange),可看 L448-L453L501-L505,所以 [(xxx)] 僅僅是為了省事的簡單寫法。

檢視 stackblitz demo 可以看到,如果修改 input 裡的值,name 變數的值也自動發生變化了,這點可從與 name 繫結的 p 標籤值自動變化看出;如果點選 button 修改了 name 的值,input 輸入框內的 value 值也發生變化了,這點可從 input 框內的值變化可看到。那 NgModel 指令是如何做到雙向繫結的呢?

在理解 NgModel 指令雙向繫結原理之前,可以先看看雙向繫結最簡單形式:

<input [value]="country" (input)="country = $event.target.value">
<button (click)="country = country + ' , China';">ChangeCountry</button>
<p>Hello {{country}}!</p>
複製程式碼

點選 button 修改 model 時,就會自動修改 input 的 value 值,即自動修改 view,資料流方向就是 model -> view;更新 input 框內值時,就會自動修改 country 這個 model 值,這點可從繫結的 p 標籤看到,這時資料流方向就是 view -> model。當然,這是最簡單且最不可擴充套件的一個雙向繫結例項,如果去設計一個指令,不僅僅需要考慮 view 的不同型別,而且還需要考慮資料校驗問題。儘管如此,這個簡單例項與 NgModel 指令本質是類似的。

如果自己設計這樣一個雙向繫結指令,那它的輸入必然是繫結的變數 name,該指令接收 name 後再去更新 input 元素的 value 值(還得支援 textarea,select 等 DOM 元素,甚至元件等自定義 DOM 元素),這樣 name 發生變化,input 的 value 也會自動變化,即 model -> view;輸出的必然是 input 元素的 value 值,然後賦值給 name,這樣 input 元素的值變化,name 值也自動變化,即 view -> model。這裡的最難點是該指令得能夠寫 DOM 元素(不管原生或者自定義 DOM 元素)的值,並且能夠監聽 DOM 元素的值變化,讀取變化的值。 所以,為了支援原生 DOM 元素或自定義 DOM 元素,為了有個好的設計模式,必然會抽象出一個介面,來幫助指令去寫入和監聽讀取 DOM 元素值,有了這個介面,事情就簡單很多了。

現在,我們需要搞明白兩個問題:name 值發生變化時,input 的 value 如何自動變化;input 的 value 變化,name 值如何自動變化?

繫結到 input 上的 NgModel 指令在例項化時,其 建構函式 會首先查詢出 ControlValueAccessor 物件,這個 ControlValueAccessor 就是上文提到的抽象出來的物件,該物件會具體負責更新和監聽讀取 DOM 元素的值。上文模板中的 input 元素不僅僅繫結了 NgModel 指令,實際上還繫結了 DefaultValueAccessor 指令,這點可以從該指令的選擇器知道,如果 input 模板是這麼寫的:

<input [ngModel]="name" (ngModelChange)="this.name=$event" type="number">
複製程式碼

那不僅僅繫結了 DefaultValueAccessor 指令,還繫結了 NumberValueAccessor 指令。

由於 DefaultValueAccessor 的 providers 屬性提供了 NG_VALUE_ACCESSOR 令牌,並且該令牌指向的物件就是 DefaultValueAccessor,所以 NgModel 建構函式中注入的 NG_VALUE_ACCESSOR 令牌包含的 ControlValueAccessor 物件陣列只有 DefaultValueAccessor 一個。如果是 type="number" 的 input,則 valueAccessors 包含 NumberValueAccessorDefaultValueAccessor 這兩個物件。建構函式中的 selectValueAccessor() 方法會依次遍歷 NG_VALUE_ACCESSOR 令牌提供的 ControlValueAccessor 物件陣列,如果是自定義的 ControlValueAccessor 優先選擇自定義的,如果是 @angular/forms 內建的 ControlValueAccessor 就選擇內建的(內建的也就 6 個),否則最後選擇預設的 ControlValueAccessorDefaultValueAccessor 物件。對於本文 demo,那就是預設的 DefaultValueAccessor 物件。注意的一點是,注入的 NG_VALUE_ACCESSOR 令牌有裝飾器 @Self,所以只能從自身去查詢這個依賴,自身的意思是 NgModel 指令自己,和它一起掛載到 input 元素的其他指令。另外,input 上沒有繫結任何 validators 指令,所以注入的 NG_VALIDATORS 和 NG_ASYNC_VALIDATORS 令牌解析的值為空,並且 input 單獨使用,沒有放在 form 元素內,或 FormGroup 繫結的元素內,所以不存在宿主控制元件容器 ControlContainer,即 parent 也為空。

NgModel 指令在首次例項化時,執行 _setUpControl() 方法,利用 ControlValueAccessor(本 demo 即 DefaultValueAccessor 物件) 把 NgModel 指令內部的 FormControl 物件與 DOM 元素繫結。由於本 demo 中,NgModel 指令繫結的 input 沒有父控制元件容器,所以會呼叫 _setUpStandalone 方法,核心方法就是 setUpControl(),該方法主要包含兩點:第一點,通過呼叫 setUpViewChangePipeline()DefaultValueAccessor 物件內註冊一個回撥函式,這樣當 input 值發生變化時,就觸發 input 事件 時,會執行這個回撥函式,而這個回撥函式的邏輯 一是更新 FormControl 的 value,二是讓 NgModel 指令丟擲 ngModelChange 事件,該事件包含的值就是當前 input 變化的新值,所以,setUpViewChangePipeline() 方法的作用就是搭建了 view -> model 的管道,這樣 view (這裡是 input) 值發生變化時,會同步 FormControl 物件的 value 值,並讓 NgModel 指令把這個新值輸出出去;第二點,通過呼叫 setUpModelChangePipeline 方法向 FormControl 物件內註冊 一個回撥,這個回撥邏輯是當 FormControl 的 value 值發生變化時(本 demo 中就是 [ngModel]="name" 時,name 值發生變化,也就是屬性值改變,這樣 isPropertyUpdated(changes, this.viewModel) 就為 true,這樣就會需要更新 FormControl 的 value 值 FormControl.setValue(value),從而會 觸發 上文說的 FormControl 物件內的回撥函式),通過呼叫 ControlValueAccessor.writeValue() 方法去修改 view (這裡是 input) 的 value 值(本 demo 中使用的是 DefaultValueAccessor.writeValue(value)),然後讓 NgModel 指令丟擲 ngModelChange 事件,該事件包含的值就是當前 FormControl 物件 變化的新值,所以,setUpModelChangePipeline() 方法的作用就是搭建了 model -> view 的管道,這樣 FormControl 物件值發生改變時,會同步更新 view 的 value,並讓 NgModel 指令把這個新值輸出出去。

通過以上的解釋,就能理解 name 值發生變化時,input 的 value 是如何自動變化的;input 的 value 發生變化時,name 值是如何自動變化的。(最好能一個個點選連結檢視原始碼,效率更高。) 一句話解釋就是:NgModel 指令初始化時先安裝了兩個回撥(一個是 view 變化時更新 FormControl 物件 value 值的回撥,另一個是 FormControl 物件 value 值變化時更新 view 值的回撥),資料流方向從 view -> model 時,更新 FormControl 物件並丟擲攜帶該值的 ngModelChange 事件,資料流方向從 model -> view 時,利用 ControlValueAccessor 去更新 view 值,同時也丟擲攜帶該值的 ngModelChange 事件。丟擲的 ngModelChange 事件包含新值,模板中的 $event 會被 @angular/compiler 特殊處理,為 ngModelChange 事件丟擲的值。

當然,本文沒有考慮存在 Validators 的情況,如果 input 模板修改為如下程式碼:

<input [ngModel]="name" (ngModelChange)="this.name=$event" required>
複製程式碼

那該模板除了繫結 NgModel 指令外,還繫結了 RequiredValidator 指令,這樣不管資料流方向是 view -> model 還是 model -> view,在資料流動之前,還需要執行驗證器,驗證資料的有效性。這樣 NgModel 的建構函式裡就會包含 一個 RequiredValidator 物件,然後 把這個 Validator 傳給 FormControl 物件,最後註冊 validatorChange 回撥,這樣以後 FormControl 值更新時就會 執行 Validators

總之,NgModel 指令來管理 model <-> view 的資料流,內部存在一個 FormControl 物件,用來讀取儲存值和驗證有效性,從 FormControl 讀取的值會賦值給外界傳進來的 model,view 是藉助 ControlValueAccessor 來讀寫值。整個 @angular/forms 包的設計也是按照這種資料流形式,並不複雜。

也可閱讀 @angular/forms 相關文章瞭解如何寫一個自定義的 ControlValueAccessor:譯 別再對 Angular 表單的 ControlValueAccessor 感到迷惑

相關文章