[譯] Angular 屬性繫結更新機制

lx1036發表於2019-03-03

原文連結:The mechanics of property bindings update in Angular

property binding mechanics

所有現代前端框架都是用元件來合成 UI,這樣很自然就會產生父子元件層級,這就需要框架提供父子元件通訊的機制。同樣,Angular 也提供了兩種方式來實現父子元件通訊:輸入輸出繫結共享服務。對於 stateless presentational components 我更喜歡輸入輸出繫結方式,然而對於 stateful container components 我使用共享服務方式。

本文主要介紹輸入輸出繫結方式,特別是當父元件輸入繫結值變化時,Angular 如何更新子元件輸入值。如果想了解 Angular 如何更新當前元件 DOM,可以檢視 譯 Angular DOM 更新機制,這篇文章也會有助於加深對本文的理解。由於我們將探索 Angular 如何更新 DOM 元素和元件的輸入繫結屬性,所以假定你知道 Angular 內部是如何表現元件和指令的,如果你不是很瞭解並且很感興趣,可以檢視 譯 為何 Angular 內部沒有發現元件, 這篇文章主要講了 Angular 內部如何使用指令形式來表示元件。而本文對於元件和指令兩個概念互換使用,因為 Angular 內部就是把元件當做指令。

模板繫結語法

你可能知道 Angular 提供了 屬性繫結語法 —— [],這個語法很通用,它可以用在子元件上,也可以用在原生 DOM 元素上。如果你想從父元件把資料傳給子元件 b-comp 或者原生 DOM 元素 span,你可以在父元件模板中這麼寫:

import { Component } from `@angular/core`;

@Component({
  moduleId: module.id,
  selector: `a-comp`,
  template: `
      <b-comp [textContent]="AText"></b-comp>
      <span [textContent]="AText"></span>
  `
})
export class AComponent {
  AText = `some`;
}
複製程式碼

你不必為原生 DOM 元素做些額外的工作,但是對於子元件 b-comp 你需要申明輸入屬性 textContent

@Component({
    selector: `b-comp`,
    template: `Comes from parent: {{textContent}}`
})
export class BComponent {
    @Input() textContent;
}
複製程式碼

這樣當父元件 AComponent.AText 屬性改變時,Angular 會自動更新子元件 BComponent.textContent 屬性,和原生元素 span.textContent 屬性。同時,還會呼叫子元件 BComponent 的生命週期鉤子函式 ngOnChanges(注:實際上還有 ngDoCheck,見下文)。

你可能好奇 Angular 是怎麼知道 BComponentspan 支援 textContent 繫結的。這是因為 Angular 編譯器在解析模板時,如果遇到簡單 DOM 元素如 span,就去查詢這個元素是否定義在 dom_element_schema_registry,從而知道它是 HTMLElement 子類,textContent 是其中的一個屬性(注:可以試試如果 span 繫結一個 [abc]=AText 就報錯,沒法識別 abc 屬性);如果遇到了元件或指令,就去檢視其裝飾器 @Component/@Directive 的後設資料 input 屬性裡是否有該繫結屬性項,如果沒有,編譯器同樣會丟擲錯誤:

Can’t bind to ‘textContent’ since it isn’t a known property of …
複製程式碼

這些知識都很好理解,現在讓我們進一步看看其內部發生了什麼。

元件工廠

儘管在子元件 BComponentspan 元素繫結了輸入屬性,但是輸入繫結更新所需要的資訊全部在父元件 AComponent 的元件工廠裡。讓我們看下 AComponent 的元件工廠程式碼:

function View_AComponent_0(_l) {
  return jit_viewDef1(0, [
     jit_elementDef_2(..., `b-comp`, ...),
     jit_directiveDef_5(..., jit_BComponent6, [], {
         textContent: [0, `textContent`]
     }, ...),
     jit_elementDef_2(..., `span`, [], [[8, `textContent`, 0]], ...)
  ], function (_ck, _v) {
     var _co = _v.component;
     var currVal_0 = _co.AText;
     var currVal_1 = `d`;
     _ck(_v, 1, 0, currVal_0, currVal_1);
  }, function (_ck, _v) {
     var _co = _v.component;
     var currVal_2 = _co.AText;
     _ck(_v, 2, 0, currVal_2);
  });
}
複製程式碼

如果你讀了 譯 Angular DOM 更新機制譯 為何 Angular 內部沒有發現元件,就會對上面程式碼中的各個檢視節點比較熟悉了。前兩個節點中,jit_elementDef_2 是元素節點,jit_directiveDef_5 是指令節點,這兩個組成了子元件 BComponent;第三個節點 jit_elementDef_2 也是元素節點,組成了 span 元素。

節點繫結

相同型別的節點使用相同的節點定義函式,但區別是接收的引數不同,比如 jit_directiveDef_5 節點定義函式引數如下:

jit_directiveDef_5(..., jit_BComponent6, [], {
    textContent: [0, `textContent`]
}, ...),
複製程式碼

其中,引數 {textContent: [0, `textContent`]} 叫做 props,這點可以檢視 directiveDef 函式的引數列表:

directiveDef(..., props?: {[name: string]: [number, string]}, ...)
複製程式碼

props 引數是一個物件,每一個鍵為繫結屬性名,對應的值為繫結索引和繫結屬性名組成的陣列,比如本例中只有一個繫結,textContent 對應的值為:

{textContent: [0, `textContent`]}
複製程式碼

如果指令有多個繫結,比如:

<b-comp [textContent]="AText" [otherProp]="AProp">
複製程式碼

props 引數值也包含兩個屬性:

jit_directiveDef5(49152, null, 0, jit_BComponent6, [], {
    textContent: [0, `textContent`],
    otherProp: [1, `otherProp`]
}, null),
複製程式碼

Angular 會使用這些值來生成當前指令節點的 binding,從而生成當前檢視的指令節點。在變更檢測時,每一個 binding 決定 Angular 使用哪種操作來更新節點和提供上下文資訊,繫結型別是通過 BindingFlags 設定的(注:每一個繫結定義是 BindingDef,它的屬性 flags: BindingFlags 決定 Angular 該採取什麼操作,比如 Class 型繫結和 Style 型繫結都會呼叫對應的操作函式,見下文)。比如,如果是屬性繫結,編譯器會設定繫結標誌位為:

export const enum BindingFlags {
    TypeProperty = 1 << 3,
複製程式碼

注:上文說完了指令定義函式的引數,下面說說元素定義函式的引數。

本例中,因為 span 元素有屬性繫結,編譯器會設定繫結引數為 [[8, `textContent`, 0]]

jit_elementDef2(..., `span`, [], [[8, `textContent`, 0]], ...)
複製程式碼

不同於指令節點,對元素節點來說,繫結引數結構是個二維陣列,因為 span 元素只有一個繫結,所以它僅僅只有一個子陣列。陣列 [8, `textContent`, 0] 中第一個引數也同樣是繫結標誌位 BindingFlags,決定 Angular 應該採取什麼型別操作(注:[8, `textContent`, 0] 中的 8 表示為 property 型繫結):

export const enum BindingFlags {
    TypeProperty = 1 << 3, // 8
複製程式碼

其他型別標誌位已經在文章 譯 Angular DOM 更新機制 有所解釋:

TypeElementAttribute = 1 << 0,
TypeElementClass = 1 << 1,
TypeElementStyle = 1 << 2,
複製程式碼

編譯器不會為指令定義提供繫結標誌位,因為指令的繫結型別也只能是 BindingFlags.TypeProperty

注:節點繫結 這一節主要講的是對於元素節點來說,每一個節點的 binding 型別是由 BindingFlags 決定的;對於指令節點來說,每一個節點的 binding 型別只能是 BindingFlags.TypeProperty

updateRenderer 和 updateDirectives

元件工廠程式碼裡,編譯器還為我們生成了兩個函式:

function (_ck, _v) {
    var _co = _v.component;
    var currVal_0 = _co.AText;
    var currVal_1 = _co.AProp;
    _ck(_v, 1, 0, currVal_0, currVal_1);
},
function (_ck, _v) {
    var _co = _v.component;
    var currVal_2 = _co.AText;
    _ck(_v, 2, 0, currVal_2);
}
複製程式碼

如果你讀了 譯 Angular DOM 更新機制,應該對第二個函式即 updateRenderer 有所熟悉。第一個函式叫做 updateDirectives。這兩個函式都是 ViewUpdateFn 型別介面,兩者都是檢視定義的屬性:

interface ViewDefinition {
  flags: ViewFlags;
  updateDirectives: ViewUpdateFn;
  updateRenderer: ViewUpdateFn;
複製程式碼

有趣的是這兩個函式的函式體基本相同,引數都是 _ck_v,並且兩個函式的對應引數都指向同一個物件,所以為何需要兩個函式?

因為在變更檢測期間,這是不同階段的兩個不同行為:

這兩個操作是在變更檢測的不同階段執行,所以 Angular 需要兩個獨立的函式分別在對應的階段呼叫:

  • updateDirectives——變更檢測的開始階段被呼叫,來更新子元件的輸入繫結屬性
  • updateRenderer——變更檢測的中間階段被呼叫,來更新當前元件的 DOM 元素

這兩個函式都會在 Angular 每次的變更檢測時 被呼叫,並且函式引數也是在這時被傳入的。讓我們看看函式內部做了哪些工作。

_ck 就是 check 的縮寫,其實就是函式 prodCheckAndUpdateNode,另一個引數就是 元件檢視資料。函式的主要功能就是從元件物件裡拿到繫結屬性的當前值,然後和檢視資料物件、檢視節點索引等一起傳入 prodCheckAndUpdateNode 函式。其中,因為 Angular 會更新每一個檢視的 DOM,所以需要傳入當前檢視的索引。如果我們有兩個 span 和兩個元件:

<b-comp [textContent]="AText"></b-comp>
<b-comp [textContent]="AText"></b-comp>
<span [textContent]="AText"></span>
<span [textContent]="AText"></span>
複製程式碼

編譯器生成的 updateRenderer 函式和 updateDirectives 函式如下:

function(_ck, _v) {
    var _co = _v.component;
    var currVal_0 = _co.AText;
    
    // update first component
    _ck(_v, 1, 0, currVal_0);
    var currVal_1 = _co.AText;
    
    // update second component
    _ck(_v, 3, 0, currVal_1);
}, 
function(_ck, _v) {
    var _co = _v.component;
    var currVal_2 = _co.AText;
    
    // update first span
    _ck(_v, 4, 0, currVal_2);
    var currVal_3 = _co.AText;

    // update second span
    _ck(_v, 5, 0, currVal_3);
}
複製程式碼

沒有什麼更復雜的東西,這兩個函式還不是重點,重點是 _ck 函式,接著往下看。

更新元素的屬性

從上文我們知道,編譯器生成的 updateRenderer 函式會在每一次變更檢測被呼叫,用來更新 DOM 元素的屬性,並且其引數 _ck 就是函式 prodCheckAndUpdateNode。對於 DOM 元素的更新,該函式經過一系列的函式呼叫後,最終會呼叫函式 checkAndUpdateElementValue,這個函式會檢查繫結標誌位是 [attr.name, class.name, style.some] 其中的哪一個,又或者是屬性繫結(注:可檢視原始碼這段 L233-L250):

case BindingFlags.TypeElementAttribute -> setElementAttribute
case BindingFlags.TypeElementClass     -> setElementClass
case BindingFlags.TypeElementStyle     -> setElementStyle
case BindingFlags.TypeProperty         -> setElementProperty;
複製程式碼

上面程式碼就是剛剛說的幾個繫結型別,當繫結標誌位是 BindingFlags.TypeProperty,會呼叫函式 setElementProperty,該函式內部也是通過呼叫 DOM Renderer 的 setProperty 方法來更新 DOM。

注:setElementProperty 函式裡這行程式碼 view.renderer.setProperty(renderNode,name, renderValue);,renderer 就是 Renderer2 interface,它僅僅是一個介面,在瀏覽器平臺下,它的實現就是 DefaultDomRenderer2

更新指令的屬性

上文中已經描述了 updateRenderer 函式是用來更新元素的屬性,而 updateDirective 是用來更新子元件的輸入繫結屬性,並且變更檢測期間傳入的引數 _ck 就是函式 prodCheckAndUpdateNode。只是進過一系列函式呼叫後,最終呼叫的函式卻是**checkAndUpdateDirectiveInline,這是因為這次節點的標誌位是 NodeFlags.TypeDirective(注:可檢視原始碼 L428-L429),checkAndUpdateDirectiveInline** 函式主要功能如下:

  1. 從當前檢視節點裡獲取元件/指令物件(注:檢視 L156

  2. 檢查元件/指令物件的繫結屬性值是否發生改變(注:檢視 L160-L199

  3. 如果屬性發生改變:

    a. 如果變更策略設定為 OnPush,設定檢視狀態為 checksEnabled(注:檢視 L438

    b. 更新子元件的繫結屬性值(注:檢視 L446

    c. 準備 SimpleChange 資料和更新檢視的 oldValues 屬性,新值替換舊值(注:檢視 L451-L454

    d. 呼叫生命週期鉤子 ngOnChanges(注:檢視 L201

  4. 如果該檢視是首次執行變更檢測,則呼叫生命週期鉤子 ngOnInit(注:檢視 L205

  5. 呼叫生命週期鉤子 ngDoCheck(注:檢視 L233

當然,只有在生命週期鉤子在元件內定義了才被呼叫,Angular 使用 NodeDef 節點標誌位來判斷是否有生命週期鉤子,如果檢視原始碼你會發現類似如下程式碼(注:檢視 L203-L207):

if (... && (def.flags & NodeFlags.OnInit)) {
  directive.ngOnInit();
}
if (def.flags & NodeFlags.DoCheck) {
  directive.ngDoCheck();
}
複製程式碼

和更新元素節點一樣,更新指令時也同樣把上一次的值儲存在檢視資料的屬性 oldValues 裡(注:即上面的 3.c 步驟)。

相關文章