虛擬DOM(二)
先介紹瀏覽器載入一個 HTML
檔案需要做哪些事,幫助我們理解為什麼我們需要虛擬 DOM
。 webkit
引擎的處理流程
所有瀏覽器的引擎工作流程都差不多,如上圖大致分5步:建立 DOM tree
–> 建立 Style Rules
-> 構建 Render tree
-> 佈局 Layout
–> 繪製 Painting
- 第一步,用
HTML
分析器,分析HTML
元素,構建一顆DOM
樹。 - 第二步:用
CSS
分析器,分析CSS
檔案和元素上的inline
樣式,生成頁面的樣式表。 - 第三步:將上面的
DOM
樹和樣式表,關聯起來,構建一顆Render
樹。這一過程又稱為Attachment
。每個DOM
節點都有attach
方法,接受樣式資訊,返回一個render
物件(又名renderer
)。這些render
物件最終會被構建成一顆Render
樹。 - 第四步:有了
Render
樹後,瀏覽器開始佈局,會為每個Render
樹上的節點確定一個在顯示屏上出現的精確座標值。 - 第五步:
Render
數有了,節點顯示的位置座標也有了,最後就是呼叫每個節點的paint
方法,讓它們顯示出來。
當你用傳統的源生 api
或 jQuery
去操作 DOM
時,瀏覽器會從構建 DOM
樹開始從頭到尾執行一遍流程。比如當你在一次操作時,需要更新 10
個 DOM
節點,理想狀態是一次性構建完 DOM
樹,再執行後續操作。但瀏覽器沒這麼智慧,收到第一個更新 DOM
請求後,並不知道後續還有9次更新操作,因此會馬上執行流程,最終執行10次流程。顯然例如計算 DOM
節點的座標值等都是白白浪費效能,可能這次計算完,緊接著的下一個 DOM
更新請求,這個節點的座標值就變了,前面的一次計算是無用功。
- 即使計算機硬體一直在更新迭代,操作
DOM
的代價仍舊是昂貴的,頻繁操作還是會出現頁面卡頓,影響使用者的體驗。真實的DOM
節點,哪怕一個最簡單的div也包含著很多屬性,可以打印出來直觀感受一下
虛擬 DOM
就是為了解決這個瀏覽器效能問題而被設計出來的。例如前面的例子,假如一次操作中有 10
次更新 DOM
的動作,虛擬 DOM
不會立即操作 DOM
,而是將這 10
次更新的 diff
內容儲存到本地的一個 js
物件中,最終將這個js物件一次性 attach
到 DOM
樹上,通知瀏覽器去執行繪製工作,這樣可以避免大量的無謂的計算量
二、實現虛擬DOM
<div id="real-container"> <p>Real DOM</p> <div>cannot update</div> <ul> <li className="item">Item 1</li> <li className="item">Item 2</li> <li className="item">Item 3</li> </ul> </div>
用 js
物件來模擬 DOM
節點如下
const tree = Element('div', { id: 'virtual-container' }, [ Element('p', {}, ['Virtual DOM']), Element('div', {}, ['before update']), Element('ul', {}, [ Element('li', { class: 'item' }, ['Item 1']), Element('li', { class: 'item' }, ['Item 2']), Element('li', { class: 'item' }, ['Item 3']), ]), ]); const root = tree.render(); document.getElementById('virtualDom').appendChild(root);
用 js
物件模擬 DOM
節點的好處是,頁面的更新可以先全部反映在 js
物件上,操作記憶體中的 js
物件的速度顯然要快多了。等更新完後,再將最終的 js
物件對映成真實的 DOM
,交由瀏覽器去繪製
function Element(tagName, props, children) { if (!(this instanceof Element)) { return new Element(tagName, props, children); } this.tagName = tagName; this.props = props || {}; this.children = children || []; this.key = props ? props.key : undefined; let count = 0; this.children.forEach((child) => { if (child instanceof Element) { count += child.count; } count++; }); this.count = count; }
第一個引數是節點名(如 div
),第二個引數是節點的屬性(如 class
),第三個引數是子節點(如 ul
的 li
)。除了這三個引數會被儲存在物件上外,還儲存了 key
和 count
有了 js
物件後,最終還需要將其對映成真實的 DOM
Element.prototype.render = function() { const el = document.createElement(this.tagName); const props = this.props; for (const propName in props) { setAttr(el, propName, props[propName]); } this.children.forEach((child) => { const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child); el.appendChild(childEl); }); return el; };
根據 DOM
名呼叫源生的 createElement
建立真實 DOM
,將 DOM
的屬性全都加到這個 DOM
元素上,如果有子元素繼續遞迴呼叫建立子元素,並 appendChild
掛到該 DOM
元素上。這樣就完成了從建立虛擬 DOM
到將其對映成真實 DOM
的全部工作
三、Diff演算法
我們已經完成了建立虛擬 DOM
並將其對映成真實 DOM
的工作,這樣所有的更新都可以先反映到虛擬 DOM
上,如何反映呢?需要明確一下 Diff
演算法
- 兩棵樹如果完全比較時間複雜度是
O(n^3)
-
React
的Diff
演算法的時間複雜度是O(n)
。要實現這麼低的時間複雜度,意味著只能平層地比較兩棵樹的節點,放棄了深度遍歷 - 這樣做,似乎犧牲了一定的精確性來換取速度,但考慮到現實中前端頁面通常也不會跨層級移動
DOM
元素,所以這樣做是最優的。
我們新建立一棵樹,用於和之前的樹進行比較
const newTree = Element('div', { id: 'virtual-container' }, [ Element('h3', {}, ['Virtual DOM']),// REPLACE Element('div', {}, ['after update']),// TEXT Element('ul', { class: 'marginLeft10' }, [// PROPS Element('li', { class: 'item' }, ['Item 1']), // Element('li', { class: 'item' }, ['Item 2']),// REORDER remove Element('li', { class: 'item' }, ['Item 3']), ]), ]);
只考慮平層地 Diff
的話,就簡單多了,只需要考慮以下4種情況
第一種是最簡單的,節點型別變了,例如下圖中的 P
變成了 h3
。我們將這個過程稱之為 REPLACE
。直接將舊節點解除安裝( componentWillUnmount
)並裝載新節點( componentWillMount
)就行了
舊節點包括下面的子節點都將被解除安裝,如果新節點和舊節點僅僅是型別不同,但下面的所有子節點都一樣時,這樣做顯得效率不高。但為了避免 O(n^3)
的時間複雜度,這樣做是值得的。這也提醒了 React
開發者,應該避免無謂的節點型別的變化,例如執行時將 div
變成 p
就沒什麼太大意義
第二種也比較簡單,節點型別一樣,僅僅屬性或屬性值變了
renderA: <ul> renderB: <ul class: 'marginLeft10'> => [addAttribute class "marginLeft10"]
我們將這個過程稱之為 PROPS
。此時不會觸發節點的解除安裝( componentWillUnmount
)和裝載( componentWillMount
)動作。而是執行節點更新( shouldComponentUpdate
到 componentDidUpdate
的一系列方法)
function diffProps(oldNode, newNode) { const oldProps = oldNode.props; const newProps = newNode.props; let key; const propsPatches = {}; let isSame = true; // find out different props for (key in oldProps) { if (newProps[key] !== oldProps[key]) { isSame = false; propsPatches[key] = newProps[key]; } } // find out new props for (key in newProps) { if (!oldProps.hasOwnProperty(key)) { isSame = false; propsPatches[key] = newProps[key]; } } return isSame ? null : propsPatches; }
- 第三種是文字變了,文字對也是一個
Text Node
,也比較簡單,直接修改文字內容就行了,我們將這個過程稱之為TEXT
- 第四種是移動,增加,刪除子節點,我們將這個過程稱之為
REORDER
在中間插入一個節點,程式員寫程式碼很簡單:$(B).after(F)。但如何高效地插入呢?簡單粗暴的做法是:解除安裝C,裝載F,解除安裝D,裝載C,解除安裝E,裝載D,裝載E。如下圖
我們寫 JSX
程式碼時,如果沒有給陣列或列舉型別定義一個 key
,就會看到下面這樣的 warning
。 React
提醒我們,沒有 key
的話,涉及到移動,增加,刪除子節點的操作時,就會用上面那種簡單粗暴的做法來更新。雖然程式執行不會有錯,但效率太低,因此 React
會給我們一個 warning
如果我們在 JSX
裡為陣列或列舉型元素增加上 key
後, React
就能根據 key
,直接找到具體的位置進行操作,效率比較高。如下圖
常見的最小編輯距離問題,可以用 Levenshtein Distance
演算法來實現,時間複雜度是 O(M*N)
,但通常我們只要一些簡單的移動就能滿足需要,降低點精確性,將時間複雜度降低到 O(max(M, N)
即可
最終 Diff
出來的結果如下
{ 1: [ {type: REPLACE, node: Element} ], 4: [ {type: TEXT, content: "after update"} ], 5: [ {type: PROPS, props: {class: "marginLeft10"}}, {type: REORDER, moves: [{index: 2, type: 0}]} ], 6: [ {type: REORDER, moves: [{index: 2, type: 0}]} ], 8: [ {type: REORDER, moves: [{index: 2, type: 0}]} ], 9: [ {type: TEXT, content: "Item 3"} ], }
四、對映成真實DOM
虛擬 DOM
有了, Diff
也有了,現在就可以將 Diff
應用到真實 DOM
上了
深度遍歷DOM將Diff的內容更新進去
function dfsWalk(node, walker, patches) { const currentPatches = patches[walker.index]; const len = node.childNodes ? node.childNodes.length : 0; for (let i = 0; i < len; i++) { walker.index++; dfsWalk(node.childNodes[i], walker, patches); } if (currentPatches) { applyPatches(node, currentPatches); } }
具體更新的程式碼如下,其實就是根據 Diff
資訊呼叫源生 API
操作 DOM
function applyPatches(node, currentPatches) { currentPatches.forEach((currentPatch) => { switch (currentPatch.type) { case REPLACE: { const newNode = (typeof currentPatch.node === 'string') ? document.createTextNode(currentPatch.node) : currentPatch.node.render(); node.parentNode.replaceChild(newNode, node); break; } case REORDER: reorderChildren(node, currentPatch.moves); break; case PROPS: setProps(node, currentPatch.props); break; case TEXT: if (node.textContent) { node.textContent = currentPatch.content; } else { // ie node.nodeValue = currentPatch.content; } break; default: throw new Error(`Unknown patch type ${currentPatch.type}`); } }); }
虛擬 DOM
的目的是將所有操作累加起來,統計計算出所有的變化後,統一更新一次 DOM