Angular的DOM更新機制

穆奈發表於2020-03-30

本文是閱讀The mechanics of DOM updates in Angular後的實踐與總結。感興趣的同學可以直接去學習一下原文。

本文旨在介紹用Angular編寫的元件在框架內的定義方式和儲存結構,以及在Angular中最常用的資料繫結與DOM更新的實現機制。

Angular中繫結資料的常見寫法

在Angular中,要實現DOM的重新整理,我們通常會使用資料繫結。而最常見的資料繫結的形式如下

<!-- app.component.html -->
<span>Hello, {{name}}! Welcome to {{city}}!</span>
複製程式碼
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  name = "Moonlight";
  city = "Guangzhou";
}
複製程式碼

AppComponent中的資料name或者city發生變化時,就會自動更新頁面DOM的顯示內容。後文將詳細解釋,這種DOM更新是如何實現的。

檢視結構及相關概念

在開始詳細解釋流程之前,這裡需要引入Angular中檢視(View)的相關概念。在編寫元件時,我們利用模板(Template)來定義頁面展示結構。但是這些模板中有很多是Angular自己定義的語法,並不能被標準的HTML所解釋。因此,Angular框架採用了一種叫做檢視(View)的資料結構來儲存模板所定義的DOM結構關係。

一個Angular元件對應會生成一個檢視定義。而該元件中的各種DOM子節點相關資料也會在檢視中通過各種節點(Node)來儲存。關於這些內容,可以通過Maxim的這篇文章瞭解更多。

在上一小節給出的例子中,我們的檢視結構非常簡單,畫出來大概是這種樣子:

| AppComponent Template |  <-------->  | AppComponent ViewDefinition |
-------------------------              -------------------------------
|      Span Node        |  <-------->  |     Span Element NodeDef    |
|      Text Node        |  <-------->  |          Text NodeDef       |
複製程式碼

其中左側是我們通過HTML定義的模板,而右邊就是在Angular中進行儲存的資料結構。

接下來,我們看一下Angular中對於這些資料結構具體是如何定義的。

ViewDefinition

檢視的介面定義中有兩項關鍵的屬性列在下面

export interface ViewDefinition extends Definition<ViewDefinitionFactory> {
  updateRenderer: ViewUpdateFn;
  nodes: NodeDef[];
}
複製程式碼

其中updateRenderer是用於更新渲染的函式,在後文中會遇到。而nodes則是該檢視中所有的節點列表。

NodeDef

再來看一下,檢視中的節點介面是如何定義的。這裡也摘選幾項重要的屬性如下:

export interface NodeDef {
  flags: NodeFlags;
  bindings: BindingDef[];
}
複製程式碼

其中flags用於標記該節點是一種什麼型別的節點,該屬性的取值從NodeFlags這個列舉常量中選取。例如我們上面的例子中就用到了NodeFlags.TypeElementNodeFlags.TypeText這兩種:

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

而另一個屬性bindings則記錄了在這一個節點中需要進行資料繫結的內容。這一塊是用於實現DOM更新的關鍵,後面將會詳細介紹。

資料繫結的實現

上文中分析了我們在編寫元件時定義的模板與內在的檢視資料結構之間的聯絡。那麼這一小節我們來看一下資料繫結具體是如何實現的。

首先,我們可以用Angular的編譯器ngc對將本文最開始示例中的元件進行編譯並看一下輸出結果。配置一下編譯指令碼:

// package.json
{
    "scripts": {
        "compile": "ngc"
    }
}
複製程式碼

然後執行npm run compile。應該可以在dist/out-tsc目錄下面看到輸出結果。我們重點關注dist/out-tsc/src/app/app.component.ngfactory.js這個檔案。它是我們定義的AppComponent元件經ngc編譯之後所得到的元件工廠檔案。在該檔案中,我們重點關注這個函式

export function View_AppComponent_0(_l) { 
  return i1.ɵvid(0, 
    [
      (_l()(), i1.ɵeld(0, 0, null, null, 1, "span", [], null, null, null, null, null)), 
      (_l()(), i1.ɵted(1, null, ["Hello, ", "! Welcome to ", "!"]))
    ], null, function (_ck, _v) { 
      var _co = _v.component; 
      var currVal_0 = _co.name;
      var currVal_1 = _co.city; 
      _ck(_v, 1, 0, currVal_0, currVal_1); 
    }
  ); 
}
複製程式碼

該函式是編譯器生成的檢視工廠,用來生成上文中我們提到的檢視。在這段程式碼中,i1.ɵvid, i1.ɵeldi1.ɵted分別對應的是viewDef, elementDef, textDef這三個函式(這裡vid, eldted分別就是上面三個函式名的縮寫,這種縮寫對應關係可以在codegen_private_exports.ts中找到)。

我們簡單看一下上面三個函式的定義程式碼可知

  • elementDef返回HTML元素相關的節點定義
  • textDef函式返回文字相關的節點定義
  • 上述兩個函式返回的節點作為引數傳遞給viewDef函式,用於生成檢視的定義

為了瞭解繫結是如何實現的,我們著重看一下textDef函式中的內容。因為繫結發生在這個節點中

// text.ts
export function textDef(
    checkIndex: number, ngContentIndex: number | null, staticText: string[]): NodeDef {
  const bindings: BindingDef[] = [];
  for (let i = 1; i < staticText.length; i++) {
    bindings[i - 1] = {
      flags: BindingFlags.TypeProperty,
      name: null,
      ns: null,
      nonMinifiedName: null,
      securityContext: null,
      suffix: staticText[i],
    };
  }
  
  return {
      ...,
      text: {prefix: staticText[0]},
      bindings,
      ...
  }
複製程式碼

從上述程式碼可以看出,在該函式中,將文字節點以資料繫結的位置為界分段記錄在節點定義中。以我們前面給出的例子來說明,呼叫此函式的方式為

textDef(1, null, ["Hello, ", "! Welcome to ", "!"]);
複製程式碼

因此會生成如下的節點

{
    ...,
    text: { prefix: "Hello, " },
    bindings: [
        {suffix: "! Welcome to "},
        {suffix: "!"},
    ],
    ...
}
複製程式碼

到這裡就算是將DOM中的文字結構以一種抽象的資料結構記錄下來了。但是,利用這樣的結構又怎麼實現DOM的更新呢?下一小節我們來解釋這個過程。

DOM更新的實現

首先,我們回顧一下上面的viewDef函式定義

export function viewDef(
    flags: ViewFlags, nodes: NodeDef[], updateDirectives?: null | ViewUpdateFn,
    updateRenderer?: null | ViewUpdateFn): ViewDefinition {}
複製程式碼

該函式的最後一個引數是一個名為updateRenderer的函式。根據這篇文章的介紹,在Angular對元件進行變更檢測時會執行元件的updateRenderer函式。那麼,結合我們前文中例子中的編譯結果,來看一下這個函式中都做了什麼事情

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

該函式中第一個引數是check函式,在不同的環境下指向不同的實現方式。例如,在生產環境中,該函式的具體實現是prodCheckAndUpdateNode。而第二個引數是我們在利用createView函式例項化ViewDefinition時生成的ViewData資料。

這段函式的內容比較直白,即獲取到當前元件中的name和city屬性的值,然後用prodCheckAndUpdateNode函式來進行變更檢測和更新。那麼我們來進一步看一下prodCheckAndUpdateNode函式中具體做了什麼事情。

通過在原始碼中的逐級索引(建議將原始碼clone下來後,在本地利用IDE來跳轉閱讀,會更加方便),我們整理出核心的檢測思路如下:

  • checkAndUpdateTextInline:在這個函式中會對建立的每組繫結進行檢查(前文的例子中我們建立了兩組bindings)。如果有任何一組繫結發生了變化,都需要對DOM進行更新。
  • checkBinding:在檢查的核心邏輯中我們可以看出,如果是第一次檢查,則一定會觸發後續的更新邏輯。否則,會通過對比儲存的舊資料和新資料是否相同來判斷是否觸發更新邏輯。
  • _addInterpolationPart:這個函式將每一組繫結中的屬性值以及對應的suffix拼接起來然後返回。通過多次呼叫這個函式,然後加上開頭的prefix就可以形成最終的文字內容,即
value = prefix +
        v0 + bindings[0].suffix +
        v1 + bindings[1].suffix;
        
複製程式碼
  • 最後再利用渲染器將該結果更新到DOM節點中,至於renderer.setValue的具體實現,則會根據渲染器的不同而有所區別,就不在此文中進行深入討論了。

總結

本文梳理了Angular實現資料繫結和DOM更新的基本流程和核心邏輯。當然,本文並沒有覆蓋所有資料繫結的情形,例如通過HTML元素的屬性進行繫結或者反過來對HTML元素的事件進行繫結等。不過,我們可以同樣按照本文中的思路,先寫一個簡單的示例,然後檢視ngc編譯後的結果,再去閱讀原始碼,就能很清楚地理解框架中的實現原理了。

相關文章