[譯] Angular DOM 更新機制

lx1036發表於2018-04-15

原文連結:The mechanics of DOM updates in Angular

DOM Update

由模型變化觸發的 DOM 更新是所有前端框架的重要功能(注:即保持 model 和 view 的同步),當然 Angular 也不例外。定義一個如下模板表示式:

<span>Hello {{name}}</span>
複製程式碼

或者類似下面的屬性繫結(注:這與上面程式碼等價):

<span [textContent]="'Hello ' + name"></span>
複製程式碼

當每次 name 值發生變化時,Angular 會神奇般的自動更新 DOM 元素(注:最上面程式碼是更新 DOM 文字節點,上面程式碼是更新 DOM 元素節點,兩者是不一樣的,下文解釋)。這表面上看起來很簡單,但是其內部工作相當複雜。而且,DOM 更新僅僅是 Angular 變更檢測機制 的一部分,變更檢測機制主要由以下三步組成:

  • DOM updates(注:即本文將要解釋的內容)
  • child components Input bindings updates
  • query list updates

本文主要探索變更檢測機制的渲染部分(即 DOM updates 部分)。如果你之前也對這個問題很好奇,可以繼續讀下去,絕對讓你茅塞頓開。

在引用相關原始碼時,假設程式是以生產模式執行。讓我們開始吧!

程式內部架構

在探索 DOM 更新之前,我們先搞清楚 Angular 程式內部究竟是如何設計的,簡單回顧下吧。

檢視

從我的這篇文章 Here is what you need to know about dynamic components in Angular 知道 Angular 編譯器會把程式中使用的元件編譯為一個工廠類(factory)。例如,下面程式碼展示 Angular 如何從工廠類中建立一個元件(注:這裡作者邏輯貌似有點亂,前一句說的 Angular 編譯器編譯的工廠類,其實是編譯器去做的,不需要開發者做任何事情,是自動化的事情;而下面程式碼說的是開發者如何手動通過 ComponentFactory 來建立一個 Component 例項。總之,他是想說元件是怎麼被例項化的):

const factory = r.resolveComponentFactory(AComponent);
componentRef: ComponentRef<AComponent> = factory.create(injector);
複製程式碼

Angular 使用這個工廠類來例項化 View Definition ,然後使用 viewDef 函式來 建立檢視。Angular 內部把一個程式看作為一顆檢視樹,一個程式雖然有眾多元件,但有一個公共的檢視定義介面來定義由元件生成的檢視結構(注:即 ViewDefinition Interface),當然 Angular 使用每一個元件物件來建立對應的檢視,從而由多個檢視組成檢視樹。(注:這裡有一個主要概念就是檢視,其結構就是 ViewDefinition Interface

元件工廠

元件工廠大部分程式碼是由編譯器生成的不同檢視節點組成的,這些檢視節點是通過模板解析生成的(注:編譯器生成的元件工廠是一個返回值為函式的函式,上文的 ComponentFactory 是 Angular 提供的類,供手動呼叫。當然,兩者指向同一個事物,只是表現形式不同而已)。假設定義一個元件的模板如下:

<span>I am {{name}}</span>
複製程式碼

編譯器會解析這個模板生成包含如下類似的元件工廠程式碼(注:這只是最重要的部分程式碼):

function View_AComponent_0(l) {
    return jit_viewDef1(0,
        [
          jit_elementDef2(0,null,null,1,'span',...),
          jit_textDef3(null,['I am ',...])
        ], 
        null,
        function(_ck,_v) {
            var _co = _v.component;
            var currVal_0 = _co.name;
            _ck(_v,1,0,currVal_0);
複製程式碼

注:由 AppComponent 元件編譯生成的工廠函式完整程式碼如下

 (function(jit_createRendererType2_0,jit_viewDef_1,jit_elementDef_2,jit_textDef_3) {
     var styles_AppComponent = [''];
     var RenderType_AppComponent = jit_createRendererType2_0({encapsulation:0,styles:styles_AppComponent,data:{}});
     function View_AppComponent_0(_l) {
         return jit_viewDef_1(0,
            [
                (_l()(),jit_elementDef_2(0,0,null,null,1,'span',[],null,null,null,null,null)),
                (_l()(),jit_textDef_3(1,null,['I am ','']))
            ],
            null,
            function(_ck,_v) {
    	        var _co = _v.component;
    	        var currVal_0 = _co.name;
    	        _ck(_v,1,0,currVal_0);
           });
    }
 return {RenderType_AppComponent:RenderType_AppComponent,View_AppComponent_0:View_AppComponent_0};})
複製程式碼

上面程式碼描述了檢視的結構,並在例項化元件時會被呼叫。jit_viewDef_1 其實就是 viewDef 函式,用來建立檢視(注:viewDef 函式很重要,因為檢視是呼叫它建立的,生成的檢視結構即是 ViewDefinition)。

viewDef 函式的第二個引數 nodes 有些類似 html 中節點的意思,但卻不僅僅如此。上面程式碼中第二個引數是一個陣列,其第一個陣列元素 jit_elementDef_2 是元素節點定義,第二個陣列元素 jit_textDef_3 是文字節點定義。Angular 編譯器會生成很多不同的節點定義,節點型別是由 NodeFlags 設定的。稍後我們將看到 Angular 如何根據不同節點型別來做 DOM 更新。

本文只對元素和文字節點感興趣:

export const enum NodeFlags {
    TypeElement = 1 << 0, 
    TypeText = 1 << 1
複製程式碼

讓我們簡要擼一遍。

注:上文作者說了一大段,其實核心就是,程式是一堆檢視組成的,而每一個檢視又是由不同型別節點組成的。而本文只關心元素節點和文字節點,至於還有個重要的指令節點在另一篇文章。

元素節點的結構定義

元素節點結構 是 Angular 編譯每一個 html 元素生成的節點結構,它也是用來生成元件的,如對這點感興趣可檢視 Here is why you will not find components inside Angular。元素節點也可以包含其他元素節點和文字節點作為子節點,子節點數量是由 childCount 設定的。

所有元素定義是由 elementRef 函式生成的,而工廠函式中的 jit_elementDef_2() 就是這個函式。elementRef() 主要有以下幾個一般性引數:

Name Description
childCount specifies how many children the current element have
namespaceAndName the name of the html element(注:如 'span')
fixedAttrs attributes defined on the element

還有其他的幾個具有特定效能的引數:

Name Description
matchedQueriesDsl used when querying child nodes
ngContentIndex used for node projection
bindings used for dom and bound properties update
outputs, handleEvent used for event propagation

本文主要對 bindings 感興趣。

注:從上文知道檢視(view)是由不同型別節點(nodes)組成的,而元素節點(element nodes)是由 elementRef 函式生成的,元素節點的結構是由 ElementDef 定義的。

文字節點的結構定義

文字節點結構 是 Angular 編譯每一個 html 文字 生成的節點結構。通常它是元素定義節點的子節點,就像我們本文的示例那樣(注:<span>I am {{name}}</span>span 是元素節點,I am {{name}} 是文字節點,也是 span 的子節點)。這個文字節點是由 textDef 函式生成的。它的第二個引數以字串陣列形式傳進來(注: Angular v5.* 是第三個引數)。例如,下面的文字:

<h1>Hello {{name}} and another {{prop}}</h1>
複製程式碼

將要被解析為一個陣列:

["Hello ", " and another ", ""]
複製程式碼

然後被用來生成正確的繫結:

{
  text: 'Hello',
  bindings: [
    {
      name: 'name',
      suffix: ' and another '
    },
    {
      name: 'prop',
      suffix: ''
    }
  ]
}
複製程式碼

在髒檢查(注:即變更檢測)階段會這麼用來生成文字:

text
+ context[bindings[0][property]] + context[bindings[0][suffix]]
+ context[bindings[1][property]] + context[bindings[1][suffix]]
複製程式碼

注:同上,文字節點是由 textDef 函式生成的,結構是由 TextDef 定義的。既然已經知道了兩個節點的定義和生成,那節點上的屬性繫結, Angular 是怎麼處理的呢?

節點的繫結

Angular 使用 BindingDef 來定義每一個節點的繫結依賴,而這些繫結依賴通常是元件類的屬性。在變更檢測時 Angular 會根據這些繫結來決定如何更新節點和提供上下文資訊。具體哪一種操作是由 BindingFlags 決定的,下面列表展示了具體的 DOM 操作型別:

Name Construction in template
TypeElementAttribute attr.name
TypeElementClass class.name
TypeElementStyle style.name

元素和文字定義根據這些編譯器可識別的繫結標誌位,內部建立這些繫結依賴。每一種節點型別都有著不同的繫結生成邏輯(注:意思是 Angular 會根據 BindingFlags 來生成對應的 BindingDef)。

更新渲染器

最讓我們感興趣的是 jit_viewDef_1 中最後那個引數:

function(_ck,_v) {
   var _co = _v.component;
   var currVal_0 = _co.name;
   _ck(_v,1,0,currVal_0);
});
複製程式碼

這個函式叫做 updateRenderer。它接收兩個引數:_ck_v_ckcheck 的簡寫,其實就是 prodCheckAndUpdateNode 函式,而 _v 就是當前檢視物件。updateRenderer 函式會在 每一次變更檢測時 被呼叫,其引數 _ck_v 也是這時被傳入。

updateRenderer 函式邏輯主要是,從元件物件的繫結屬性獲取當前值,並呼叫 _ck 函式,同時傳入檢視物件、檢視節點索引和繫結屬性當前值。重要一點是 Angular 會為每一個檢視執行 DOM 更新操作,所以必須傳入檢視節點索引引數(注:這個很好理解,上文說了 Angular 會依次對每一個 view 做模型檢視同步過程)。你可以清晰看到 _ck 引數列表:

function prodCheckAndUpdateNode(
    view: ViewData, 
    nodeIndex: number, 
    argStyle: ArgumentType, 
    v0?: any, 
    v1?: any, 
    v2?: any,
複製程式碼

nodeIndex 是檢視節點的索引,如果你模板中有多個表示式:

<h1>Hello {{name}}</h1>
<h1>Hello {{age}}</h1>
複製程式碼

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

var _co = _v.component;

// here node index is 1 and property is `name`
var currVal_0 = _co.name;
_ck(_v,1,0,currVal_0);

// here node index is 4 and bound property is `age`
var currVal_1 = _co.age;
_ck(_v,4,0,currVal_1);
複製程式碼

更新 DOM

現在我們已經知道 Angular 編譯器生成的所有物件(注:已經有了 view,element node,text node 和 updateRenderer 這幾個道具),現在我們可以探索如何使用這些物件來更新 DOM。

從上文我們知道變更檢測期間 updateRenderer 函式傳入的一個引數是 _ck 函式,而這個函式就是 prodCheckAndUpdateNode。這個函式在繼續執行後,最終會呼叫 checkAndUpdateNodeInline ,如果繫結屬性的數量超過 10,Angular 還提供了 checkAndUpdateNodeDynamic 這個函式(注:兩個函式本質一樣)。

checkAndUpdateNodeInline 函式會根據不同檢視節點型別來執行對應的檢查更新函式:

case NodeFlags.TypeElement   -> checkAndUpdateElementInline
case NodeFlags.TypeText      -> checkAndUpdateTextInline
case NodeFlags.TypeDirective -> checkAndUpdateDirectiveInline
複製程式碼

讓我們看下這些函式是做什麼的,至於 NodeFlags.TypeDirective 可以檢視我寫的文章 The mechanics of property bindings update in Angular

注:因為本文只關注 element node 和 text node

元素節點

對於元素節點,會呼叫函式 checkAndUpdateElementInline 以及 checkAndUpdateElementValuecheckAndUpdateElementValue 函式會檢查繫結形式是否是 [attr.name, class.name, style.some] 或是屬性繫結形式:

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

然後使用渲染器對應的方法來對該節點執行對應操作,比如使用 setElementClass 給當前節點 span 新增一個 class

文字節點

對於文字節點型別,會呼叫 checkAndUpdateTextInline ,下面是主要部分:

if (checkAndUpdateBinding(view, nodeDef, bindingIndex, newValue)) {
    value = text + _addInterpolationPart(...);
    view.renderer.setValue(DOMNode, value);
}
複製程式碼

它會拿到 updateRenderer 函式傳過來的當前值(注:即上文的 _ck(_v,4,0,currVal_1);),與上一次變更檢測時的值相比較。檢視資料包含有 oldValues 屬性,如果屬性值如 name 發生變化,Angular 會使用最新 name 值合成最新的字串文字,如 Hello New World,然後使用渲染器更新 DOM 上對應的文字。

注:更新元素節點和文字節點都提到了渲染器(renderer),這也是一個重要的概念。每一個檢視物件都有一個 renderer 屬性,即是 Renderer2 的引用,也就是元件渲染器,DOM 的實際更新操作由它完成。因為 Angular 是跨平臺的,這個 Renderer2 是個介面,這樣根據不同 Platform 就選擇不同的 Renderer。比如,在瀏覽器裡這個 Renderer 就是 DOMRenderer,在服務端就是 ServerRenderer,等等。從這裡可看出,Angular 框架設計做了很好的抽象。

結論

我知道有大量難懂的資訊需要消化,但是隻要理解了這些知識,你就可以更好的設計程式或者去除錯 DOM 更新相關的問題。我建議你按照本文提到的原始碼邏輯,使用偵錯程式或 debugger 語句 一步步去除錯原始碼。

相關文章