Preact VDOM 工作流程圖
虛擬DOM (VDOM,也稱為 VNode) 是非常神奇的,同時也是複雜難懂的。 React,Preact 以及其他類似的 JS 庫都使用了虛擬 DOM 技術作為核心。可惜我找不到任何靠譜的文章或者文件可以簡單又清楚解釋清虛擬DOM的內部細節。所以,我就想到自己動手寫一篇。
注:這是一篇很長的部落格。為了讓內容更容易理解,我新增了很多圖片。這也導致這篇部落格看上去更長了。
在這篇部落格中,我是基於 Preact 的程式碼和 VDOM 機制來介紹的。因為 Preact 程式碼量更少,你在以後也可以不費力地自己看看原始碼。但是我覺得絕大部分的概念也同樣適用於 React。
我希望讀者通過這篇部落格可以更好地理解虛擬DOM,並期待你們可以為 React 和 Preact 等開源專案提供貢獻。
在這篇部落格中,我會通過一個簡單的例子來仔細地介紹虛擬DOM的每個場景,給大家虛擬DOM是如何工作的。特別地,我會介紹以下內容:
- Babel 和 JSX
- 建立 VNode – 單個虛擬 DOM 元素
- 處理元件和子元件
- 初始渲染和建立 DOM 元素
- 再次渲染
- 刪除 DOM 元素
- 替換 DOM 元素
演示程式
演示程式是一個簡單的可篩選的搜尋程式,包含了兩個元件 FilteredList 和 List。List 元件會渲染一個城市列表(預設情況是 California 和 New York)。示例還有一個搜尋框,可以根據搜尋框的輸入內容來篩選列表。十分直接了當。
概覽
首先,我們用 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』
這就是我們需要引入 JSX 的原因。本質上來說,JSX 就是讓我們愉快地在 JS 中寫 HTML!同時,也允許我們在花括號裡 {} 使用 JS。
如下所示,JSX 可以幫助我們很容易地編寫元件
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-js
3. Babel JSX (React vs Preact)
預設條件下,Babel 會把 JSX 轉譯成 React.createElement 呼叫,因為它預設就是支援的 React。
左邊是 JSX,右邊是轉譯成 React 版的 JS
但我們可以通過新增『Babel Pragma』引數,很容易地把這個函式名換成任何我們想要的,比如 Preact 使用的 『h』:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Option 1: // .babelrc { "plugins": [ [ "transform-react-jsx", {"pragma": "h"} ] ] } Option 2: // 在每個 JSX 檔案的第一行新增這一行註釋 /** @jsx h*/ |
使用 Babel Pragma 來指定 h 函式
4. 掛載到真實 DOM 的主入口
不僅是在元件的『render』函式中的程式碼需要被轉譯成『h』函式,初始的掛載入口也需要。
這就是開始執行的位置,一切的開始!
1 2 3 4 |
// Mount to real DOM render(FilteredList/>, document.getElementById(‘app’)); // Converted to "h": render(h(FilteredList), document.getElementById(‘app’)); |
5.『h』函式的返回值
『h』函式使用 JSX 的返回值作為引數,建立了一個叫『VNode』的東西(React 的『createElement』建立 ReactElement)。一個 Preact 的『VNode』(或者是 React 的 『Element』)只是一個 JS 物件,代表著一個 DOM 結點,其中包含了它的屬性和子結點。
VNode 大概是這樣的:
1 2 3 4 5 |
{ nodeName: '', attributes: {}, children: [] } |
舉個例子,我的演示程式中搜尋框 Input 的 VNode 應該是這樣的:
1 2 3 4 5 6 7 8 9 |
{ nodeName: 'input', attributes: { type: 'text', placeholder: 'Search', onChange: '' }, children: [] } |
『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
等生命週期事件是何時被呼叫的。
注:這個圖看上去很複雜,不要擔心,我們會逐個分章節一步一步地詳細介紹。
是的,很難一次全部讀懂它。所以讓我們把它分解成多個章節,一步一步來介紹。
注:當我們討論生命週期中的某部分時,我會在圖中用黃色高亮區域把它們標註出來。
場景1:應用程式的建立
1.1 為給定的元件建立 VNode (Virtual DOM)
圖中的高亮區域展示了建立元件 VNode(Vitual DOM) 樹的迴圈。注意這裡沒有建立子元件的 VNode,那是另外一個迴圈。
黃色高亮的部分展示了 VNode 的建立過程
下面這張圖展示了我們的應用首次載入時發生了什麼。框架完成時得到了 FilteredList
元件的一個帶有子結點和屬性的 VNode。
注:在這個過程中,
componentWillMount
和render
這兩個生命週期方法被呼叫了(注意上圖中的綠色框體)。
相關程式碼
絕大部分的生命週期事件,諸如:componentWillMount,render 都可以在這裡找到:https://github.com/developit/preact/blob/master/src/vdom/component.js#L101
1.2 如果不是元件,那麼建立一個真實 DOM
在這一步中,我們會為父結點(div)建立真實的 DOM 元素,並且遍歷處理子結點(input
和 List
)。
高亮的部分展現了為子元件建立真實 DOM 的處理過程
如下圖所示,現在我們就得到了 div
:
相關程式碼
document.createElement: https://github.com/developit/preact/blob/master/src/dom/recycler.js
1.3 重複子結點
現在,這個迴圈是對每個子結點重複以上動作。在我們的應用中,我們將會重複 input
和 List
。
重複處理每個子結點
1.4 處理子結點並新增將其新增到父結點
在這一步中,我們會處理葉子結點。由於 input
擁有父結點 div
,我們就把 input 作為子結點新增到 div
中。接著 input
的處理流程結束,繼續處理 List
( div
的第二個子結點)。
完成對子結點的處理
此時,我們的應用是這樣的:
注意:在建立
input
之後,由於它沒有任何子結點,因此對它的處理結束。但這裡並不是立即繼續迴圈並建立List
。而是先將input
新增到父結點div
,而後再返回處理List
。相關程式碼:
appendChild: https://github.com/developit/preact/blob/master/src/vdom/diff.js
1.5 處理子元件
控制流程返回到步驟 1.1,對 List
元件開始新的一輪處理。由於 List
是一個元件,所以它也會呼叫 List
的 render 方法來獲取到新的 VNode,如下所示:
對每個子元件重複以上所有的處理
當處理 List
元件的迴圈完成時,我們可以得到 List 的 VNode,如下所示:
process-child-component
相關程式碼:
buildComponentFormVNode: https://github.com/developit/preact/blob/master/src/vdom/diff.js#L102
1.6 對所有子結點重複步驟 1.1 到 1.4
現在再次對所有的子結點重複以上處理。一旦到達葉子結點時,就把它新增到父元素上並重復整個過程。
一直重複此流程,直到所有結點都被建立並新增到 DOM 樹
下邊個張圖展示了每個子結點是如何被新增的(提示:深度優先)
DOM 是如何被建立的
1.7 結束
此時,我們就完成了整個的處理過程。這裡只需要地呼叫所有元件的 componentDidMount
方法(自子元件開始,至父元件結束),然後停止。
重要提示:一旦所有的工作都完成時,我們會將真實 DOM 物件的引用新增到每個相應的元件例項上。這些引用將會幫助完成後續的操作(建立、更新、刪除),對比並避免重複建立相同的 DOM 結點。
場景2:刪除葉子結點
假設我們在 input 中輸入 cal
然後回車。這將移除第二個列表結點,另一個葉子結點(New York)則到被保留下來。
好,接下來讓我們看一下這一場景的處理流程。
2.1 以之前一樣,建立 VNode
在初始渲染之後的每個變化都稱為一個 更新(update)
。對於 更新
週期中的建立 VNode 工作,與前邊講到 建立
週期中的非常類似,就是再來一次建立 VNode。
既然是更新(不是建立)一個元件,那麼每個元件以及子元件的 componentWillReceiveProps
,shouldComponentUpdate
和 componentWillUpdate
事件將會被觸發。
額外的,更新週期,不會再次建立 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 與元件例項之間的引用關係
每當我們建立一個新 VNode 時,它的每個屬性都會與對應結點的真實 DOM 屬性做對比。如果真實 DOM 所有屬性都與新的 VNode 一致,那麼就會繼續處理下一個結點。
更新過程中 DOM 結點已經存在的處理流程
譯者注
實際上,這裡的邏輯並不是簡單地把 VNode 與 DOM 的
attributes
作對比。在 preact 中,每個 DOM 都有一個
Symbol(__preactattr__)
的屬性,這裡稱之為屬性快取
。這個屬性的值就是我們的 VNode 的所有屬性(不包含 children)。我們是用這個屬性快取
與 VNode 作對比的。具體的 diff 過程大概是這樣的:
首先,我們會先在 DOM 上找
Symbol(__preactattr__)
的屬性;如果這個屬性不存在,那麼我們會遍歷 DOM 上所有的attributes
來生成它。接著,我們一一對比
VNode
和屬性快取
的所有屬性。如果兩者完全一致,那麼我們不會對 DOM 做任何更新操作;如果 VNode 與這個屬性存在差異,我們則會更新 DOM 屬性,並同時更新屬性快取。注意,這裡 VNode 的屬性對比完成時,也同時完成了對 DOM 的更新。相關程式碼:
2.3 移除多餘的 DOM 結點
下邊這張圖展示了真實 DOM 與 VNode 之間的差異:
VNode 與 DOM 間的差
由於真實 DOM 比 VNode 多了一個 New York
結點,在下邊的圖中高亮的部分中我們會把它移除掉。同時,在所有過程完成之後,還會觸發生命週期中的 componentWillUnmount
事件。
Remove DOM node lifecycle
相關程式碼
unmountComponent: https://github.com/developit/preact/blob/master/src/vdom/component.js#L250
場景 3:移除整個元件
假設我們在篩選框中輸入 blabla。那麼 “California” 或者 “New York” 都匹配不上,所以我們根本不會去渲染子元件 “List”。這意味著,我們需要解除安裝整個元件。
如果沒有結果,那麼列表元件會被移除
FilteredList 的 “render” 的方法
移除一個元件與移除一個結點類似。當我們移除一個有元件引用的 DOM 結點時,會觸發元件的生命週期處理函式 “componentWillUnmount”,接著遞迴地刪除所有的子孫 DOM 結點。所有的元素都被刪除時,會觸發引用元件的生命週期處理函式 “componentDidUnmount”。
下面這張圖片展示了 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 的過程大概是這樣的:
- 首先,先將當前子 VNode 按屬性 key 為鍵、VNode 為值,構建成一個 Map;這裡就是為什麼
key
一定要互不相同的原因。如果 key 有衝突,那麼這個 Map 就無法構建了。 - 遍歷所有新的子 VNode;
- 使用新子 VNode 的
key
,找到在 Map 中的當前子 VNode; - 將兩者做 diff;實際上是遞迴整個 diff 演算法。沒找到對應 VNode 就是新增結點,找到了就是更新結點。
- 將此 VNode 的
key
從 Map 中移除;
- 使用新子 VNode 的
- 最後,把 Map 中剩餘的 VNode 全部解除安裝。這裡是場景 2.3 和場景 3 中移除結點的觸發點。
相關程式碼 innerDiffNode:https://github.com/developit/preact/blob/master/src/vdom/diff.js#L182
最後
我希望這篇文章可以充分地讓大家瞭解 Virtual DOM 是如何工作的,至少是 preact。
請注意我只提到了主要的一些場景,並沒有涉及到程式碼中某些的優化處理。
同時,如果你發現了任何問題,請告訴我。我非常樂意更正!