Vitual DOM 的內部工作原理

發表於2017-04-18

 

Preact VDOM 工作流程圖Preact VDOM 工作流程圖

虛擬DOM (VDOM,也稱為 VNode) 是非常神奇的,同時也是複雜難懂的。 ReactPreact 以及其他類似的 JS 庫都使用了虛擬 DOM 技術作為核心。可惜我找不到任何靠譜的文章或者文件可以簡單又清楚解釋清虛擬DOM的內部細節。所以,我就想到自己動手寫一篇。

注:這是一篇很長的部落格。為了讓內容更容易理解,我新增了很多圖片。這也導致這篇部落格看上去更長了。

在這篇部落格中,我是基於 Preact 的程式碼和 VDOM 機制來介紹的。因為 Preact 程式碼量更少,你在以後也可以不費力地自己看看原始碼。但是我覺得絕大部分的概念也同樣適用於 React。

我希望讀者通過這篇部落格可以更好地理解虛擬DOM,並期待你們可以為 React 和 Preact 等開源專案提供貢獻。

在這篇部落格中,我會通過一個簡單的例子來仔細地介紹虛擬DOM的每個場景,給大家虛擬DOM是如何工作的。特別地,我會介紹以下內容:

  1. Babel 和 JSX
  2. 建立 VNode – 單個虛擬 DOM 元素
  3. 處理元件和子元件
  4. 初始渲染和建立 DOM 元素
  5. 再次渲染
  6. 刪除 DOM 元素
  7. 替換 DOM 元素

 

演示程式

演示程式是一個簡單的可篩選的搜尋程式,包含了兩個元件 FilteredListList。List 元件會渲染一個城市列表(預設情況是 California 和 New York)。示例還有一個搜尋框,可以根據搜尋框的輸入內容來篩選列表。十分直接了當。

線上示例: http://codepen.io/rajaraodv/pen/BQxmjj

概覽

首先,我們用 JSX(html in js)來編寫元件。我們會使用 Babel 將元件轉譯成純 JS 。接著 Preact 的 『h』 hyperscript 函式會將元件再轉化成 VDOM 樹(也就是 VNode)。最終, Preact 的虛擬 DOM 演算法,按照 VDOM 生成真實的 DOM 元素,完成我們的應用。

概覽概覽

在我們深入 VDOM 生命週期的細節之前,先來理解一下 JSX;它提供了整個框架的起點。

1. Babel 和 JSX

在 React、Preact 以及類似的框架中,並沒有 HTML;取而代之,所有都是 JS。所以我們甚至需要在 JavaScript 中來編寫 HTML。但是,只用純 JS 來寫 DOM 簡直就是惡夢!

拿我們的演示程式來說,我們必須這樣寫 HTML:

我一會兒來再解釋 『h』

Vitual DOM 的內部工作原理Vitual DOM 的內部工作原理

這就是我們需要引入 JSX 的原因。本質上來說,JSX 就是讓我們愉快地在 JS 中寫 HTML!同時,也允許我們在花括號裡 {} 使用 JS。

如下所示,JSX 可以幫助我們很容易地編寫元件

Vitual DOM 的內部工作原理Vitual DOM 的內部工作原理

2. JSX 樹轉化為 JavaScript

JSX 很酷,但是它不是可用的 JS,而最終我們需要真實的 DOM。JSX 只能幫助我們簡潔地表達真實 DOM,沒有辦法再完成其他的事情。

所以我們需要一個方法來把 JSX 轉化成對應的 JSON 物件(VDOM,同時它也是一棵樹)。只有這樣我們最終才能使用它作為輸入來建立真實 DOM。我們需要一個函式來實現它。

在 Preact 中,這個函式就是 『h 函式』。它與 React 中的 『React.createElement』是等效的。

『h』代表著 hyperscript —— 最先開始在 JS 中編寫 HTML 的框架之一。

但如何把 JSX 轉化成 『h』函式呢?這就是引入 Babel 的原因了。Babel 會找到所有的 JSX 結點並把它們轉化成『h』函式呼叫。

babel-convert-jsx-to-jsbabel-convert-jsx-to-js

3. Babel JSX (React vs Preact)

預設條件下,Babel 會把 JSX 轉譯成 React.createElement 呼叫,因為它預設就是支援的 React。

左邊是 JSX,右邊是轉譯成 React 版的 JS

左邊是 JSX,右邊是轉譯成 React 版的 JS

但我們可以通過新增『Babel Pragma』引數,很容易地把這個函式名換成任何我們想要的,比如 Preact 使用的 『h』:

使用 Babel Pragma 來指定 h 函式使用 Babel Pragma 來指定 h 函式

4. 掛載到真實 DOM 的主入口

不僅是在元件的『render』函式中的程式碼需要被轉譯成『h』函式,初始的掛載入口也需要。

這就是開始執行的位置,一切的開始!

5.『h』函式的返回值

『h』函式使用 JSX 的返回值作為引數,建立了一個叫『VNode』的東西(React 的『createElement』建立 ReactElement)。一個 Preact 的『VNode』(或者是 React 的 『Element』)只是一個 JS 物件,代表著一個 DOM 結點,其中包含了它的屬性和子結點。

VNode 大概是這樣的:

舉個例子,我的演示程式中搜尋框 Input 的 VNode 應該是這樣的:

『h』函式不會建立整個樹!它只會為指定的結點建立一個 JS 物件。但由於『render』方法已經得到了樹結構的 DOM JSX,最終產出的結果就會是一個帶有子結點、孫結點的 VNode,看上去就是一棵樹。

相關的程式碼

『h』: https://github.com/developit/preact/blob/master/src/h.js

『VNode』: https://github.com/developit/preact/blob/master/src/vnode.js

『render』: https://github.com/developit/preact/blob/master/src/render.js

『buildComponentFromVNode』: https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102

Preact 的虛擬 DOM 演算法流程圖

下面的流程圖中展示了 Preact 是如何建立、更新、刪除元件以及其子元件的。同時它也展示了諸如 componentWillMount 等生命週期事件是何時被呼叫的。

注:這個圖看上去很複雜,不要擔心,我們會逐個分章節一步一步地詳細介紹。

Vitual DOM 的內部工作原理

是的,很難一次全部讀懂它。所以讓我們把它分解成多個章節,一步一步來介紹。

注:當我們討論生命週期中的某部分時,我會在圖中用黃色高亮區域把它們標註出來。

場景1:應用程式的建立

1.1 為給定的元件建立 VNode (Virtual DOM)

圖中的高亮區域展示了建立元件 VNode(Vitual DOM) 樹的迴圈。注意這裡沒有建立子元件的 VNode,那是另外一個迴圈。

黃色高亮的部分展示了 VNode 的建立過程黃色高亮的部分展示了 VNode 的建立過程

下面這張圖展示了我們的應用首次載入時發生了什麼。框架完成時得到了 FilteredList 元件的一個帶有子結點和屬性的 VNode。

注:在這個過程中,componentWillMountrender 這兩個生命週期方法被呼叫了(注意上圖中的綠色框體)。

Vitual DOM 的內部工作原理

相關程式碼

絕大部分的生命週期事件,諸如:componentWillMount,render 都可以在這裡找到:https://github.com/developit/preact/blob/master/src/vdom/component.js#L101

1.2 如果不是元件,那麼建立一個真實 DOM

在這一步中,我們會為父結點(div)建立真實的 DOM 元素,並且遍歷處理子結點(inputList)。

高亮的部分展現了為子元件建立真實 DOM 的處理過程高亮的部分展現了為子元件建立真實 DOM 的處理過程

如下圖所示,現在我們就得到了 div

Vitual DOM 的內部工作原理

相關程式碼

document.createElement: https://github.com/developit/preact/blob/master/src/dom/recycler.js

1.3 重複子結點

現在,這個迴圈是對每個子結點重複以上動作。在我們的應用中,我們將會重複 inputList

重複處理每個子結點重複處理每個子結點

1.4 處理子結點並新增將其新增到父結點

在這一步中,我們會處理葉子結點。由於 input 擁有父結點 div,我們就把 input 作為子結點新增到 div 中。接著 input 的處理流程結束,繼續處理 Listdiv的第二個子結點)。

完成對子結點的處理完成對子結點的處理

此時,我們的應用是這樣的:

Vitual DOM 的內部工作原理

注意:在建立 input 之後,由於它沒有任何子結點,因此對它的處理結束。但這裡並不是立即繼續迴圈並建立 List。而是先將 input 新增到父結點 div,而後再返回處理 List

相關程式碼:

appendChild: https://github.com/developit/preact/blob/master/src/vdom/diff.js

1.5 處理子元件

控制流程返回到步驟 1.1,對 List 元件開始新的一輪處理。由於 List 是一個元件,所以它也會呼叫 Listrender 方法來獲取到新的 VNode,如下所示:

對每個子元件重複以上所有的處理對每個子元件重複以上所有的處理

當處理 List 元件的迴圈完成時,我們可以得到 List 的 VNode,如下所示:

process-child-componentprocess-child-component

相關程式碼:

buildComponentFormVNode: https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102

1.6 對所有子結點重複步驟 1.1 到 1.4

現在再次對所有的子結點重複以上處理。一旦到達葉子結點時,就把它新增到父元素上並重復整個過程。

一直重複此流程,直到所有結點都被建立並新增到 DOM 樹一直重複此流程,直到所有結點都被建立並新增到 DOM 樹

下邊個張圖展示了每個子結點是如何被新增的(提示:深度優先)

DOM 是如何被建立的DOM 是如何被建立的

1.7 結束

此時,我們就完成了整個的處理過程。這裡只需要地呼叫所有元件的 componentDidMount 方法(自子元件開始,至父元件結束),然後停止。

Vitual DOM 的內部工作原理

重要提示:一旦所有的工作都完成時,我們會將真實 DOM 物件的引用新增到每個相應的元件例項上。這些引用將會幫助完成後續的操作(建立、更新、刪除),對比並避免重複建立相同的 DOM 結點。

場景2:刪除葉子結點

假設我們在 input 中輸入 cal 然後回車。這將移除第二個列表結點,另一個葉子結點(New York)則到被保留下來。

Vitual DOM 的內部工作原理

好,接下來讓我們看一下這一場景的處理流程。

2.1 以之前一樣,建立 VNode

在初始渲染之後的每個變化都稱為一個 更新(update) 。對於 更新 週期中的建立 VNode 工作,與前邊講到 建立 週期中的非常類似,就是再來一次建立 VNode。

既然是更新(不是建立)一個元件,那麼每個元件以及子元件的 componentWillReceivePropsshouldComponentUpdatecomponentWillUpdate 事件將會被觸發。

額外的,更新週期,不會再次建立 DOM 元素,因為它們已經存在了

譯者注

如果 DOM 元素可複用就不會再次建立。不可複用的情況主要是指標籤名發生變化。這種情況下,我們仍然會建立新的 DOM 元素,並且會把舊有的 DOM 回收掉。

例如從 div 變為 section,那麼就會建立一個新的 section 元素,替換原有 div,而 div 會被回收;

元件更新的處理流程元件更新的處理流程

相關程式碼

removeNode: https://github.com/developit/preact/blob/master/src/dom/index.js#L9

insertBefore: https://github.com/developit/preact/blob/master/src/vdom/diff.js#L253

2.2 使用真實 DOM 結點引用 & 避免重複建立結點

之前有提到過,在初始化過程中完成建立之後,每個元件都會有一個指向到對應的真實的 DOM 樹結點的引用。下邊的圖片展示了我們演示 app 當前狀態的引用關係。

DOM 與元件例項之間的引用關係DOM 與元件例項之間的引用關係

每當我們建立一個新 VNode 時,它的每個屬性都會與對應結點的真實 DOM 屬性做對比。如果真實 DOM 所有屬性都與新的 VNode 一致,那麼就會繼續處理下一個結點。

更新過程中 DOM 結點已經存在的處理流程更新過程中 DOM 結點已經存在的處理流程

譯者注

實際上,這裡的邏輯並不是簡單地把 VNode 與 DOM 的 attributes 作對比。

在 preact 中,每個 DOM 都有一個 Symbol(__preactattr__) 的屬性,這裡稱之為屬性快取。這個屬性的值就是我們的 VNode 的所有屬性(不包含 children)。我們是用這個屬性快取與 VNode 作對比的。

具體的 diff 過程大概是這樣的:

首先,我們會先在 DOM 上找 Symbol(__preactattr__) 的屬性;如果這個屬性不存在,那麼我們會遍歷 DOM 上所有的 attributes 來生成它。

接著,我們一一對比 VNode屬性快取 的所有屬性。如果兩者完全一致,那麼我們不會對 DOM 做任何更新操作;如果 VNode 與這個屬性存在差異,我們則會更新 DOM 屬性,並同時更新屬性快取。注意,這裡 VNode 的屬性對比完成時,也同時完成了對 DOM 的更新。

相關程式碼:

  1. 生成快取:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L143
  2. 使用屬性快取:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L139
  3. 對比屬性快取與 VNode 屬性:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L305

2.3 移除多餘的 DOM 結點

下邊這張圖展示了真實 DOM 與 VNode 之間的差異:

VNode 與 DOM 間的差VNode 與 DOM 間的差

由於真實 DOM 比 VNode 多了一個 New York 結點,在下邊的圖中高亮的部分中我們會把它移除掉。同時,在所有過程完成之後,還會觸發生命週期中的 componentWillUnmount 事件。

Remove DOM node lifecycleRemove DOM node lifecycle

相關程式碼

unmountComponent: https://github.com/developit/preact/blob/master/src/vdom/component.js#L250

場景 3:移除整個元件

假設我們在篩選框中輸入 blabla。那麼 “California” 或者 “New York” 都匹配不上,所以我們根本不會去渲染子元件 “List”。這意味著,我們需要解除安裝整個元件。

如果沒有結果,那麼列表元件會被移除如果沒有結果,那麼列表元件會被移除

FilteredList 的 render 的方法FilteredList 的 “render” 的方法

移除一個元件與移除一個結點類似。當我們移除一個有元件引用的 DOM 結點時,會觸發元件的生命週期處理函式 “componentWillUnmount”,接著遞迴地刪除所有的子孫 DOM 結點。所有的元素都被刪除時,會觸發引用元件的生命週期處理函式 “componentDidUnmount”。

下面這張圖片展示了 DOM 結點與元件例項之間的引用關係:

DOM 結點與元件例項之間的引用關係DOM 結點與元件例項之間的引用關係

下面的流程圖中高亮的部分展示了移除/解除安裝元件的處理過程:

移除並解除安裝元件移除並解除安裝元件

相關程式碼

unmountComponent: https://github.com/developit/preact/blob/master/src/vdom/component.js#L250

子結點 diff 演算法

譯者注:對於子結點的 diff 計算是 virtual dom 演算法中至關重要的一個環節。但原文沒有涉及到其中的細節,因此譯者補充這一小節。

在處理完 VNode 的自身屬性後,會對子結點進行 diff 計算;為了提高這個計算的效能,我們在框架中強制要求每個子 VNode 都必須有一個屬性 key,字串型別,並且每個 key 互不相同。我們需要使用 key 來構建索引,加速子 VNode 的匹配過程。

子結點 diff 的過程大概是這樣的:

  1. 首先,先將當前子 VNode 按屬性 key 為鍵、VNode 為值,構建成一個 Map;這裡就是為什麼 key 一定要互不相同的原因。如果 key 有衝突,那麼這個 Map 就無法構建了。
  2. 遍歷所有新的子 VNode;
    1. 使用新子 VNode 的 key,找到在 Map 中的當前子 VNode;
    2. 將兩者做 diff;實際上是遞迴整個 diff 演算法。沒找到對應 VNode 就是新增結點,找到了就是更新結點。
    3. 將此 VNode 的 key 從 Map 中移除;
  3. 最後,把 Map 中剩餘的 VNode 全部解除安裝。這裡是場景 2.3 和場景 3 中移除結點的觸發點。

相關程式碼 innerDiffNode:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L182

最後

我希望這篇文章可以充分地讓大家瞭解 Virtual DOM 是如何工作的,至少是 preact。

請注意我只提到了主要的一些場景,並沒有涉及到程式碼中某些的優化處理。

同時,如果你發現了任何問題,請告訴我。我非常樂意更正!

相關文章