學習Virtual Dom筆記
實現虛擬(Virtual) Dom
把一個 div
元素的屬性打印出來,如下:
可以看到僅僅是第一層,真正 DOM
的元素是非常龐大的,這也是 DOM
載入慢的原因。
相對於 DOM
物件,原生的 JavaScript
物件處理起來更快,而且更簡單。 DOM
樹上的結構、屬性資訊都可以用 JavaScript
物件表示出來:
var element = { tagName: 'ul', // 節點標籤名 props: { // DOM的屬性,用一個物件儲存鍵值對 id: 'list' }, children: [ // 該節點的子節點 {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]}, {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]}, {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]}, ] }
上面對應的 HTML
寫法是:
<ul id='list'> <li class='item'>Item 1</li> <li class='item'>Item 2</li> <li class='item'>Item 3</li> </ul>
DOM
樹的資訊可以用 JavaScript
物件表示出來,則說明可以用 JavaScript
物件去表示樹結構來構建一棵真正的 DOM
樹。
狀態變更->重新渲染整個檢視的方式可以用新渲染的物件樹去和舊的樹進行對比,記錄這兩棵樹的差異。兩者的不同之處就是我們需要對頁面真正的 DOM
操作,然後把它們應用在真正的 DOM
樹上,頁面就變更了。這樣可以做到:檢視的結構確實是整個全新渲染了,但是最後操作 DOM
的只有變更不同的地方。
Virtual DOM演算法,可以歸納為以下幾個步驟:
- 用JavaScript物件結構表示
DOM
樹的結構,然後用這個樹構建一個真正的DOM
樹,插到文件當中 - 當狀態變更的時候,重新構建一棵新的物件樹。然後用新的樹和舊的樹進行比較,記錄兩棵樹的差異
- 把
2
所記錄的差異應用到步驟1所構建的的真正的DOM
樹上,檢視就更新了
Virtual DOM
本質就是在JS和DOM之間做了一個快取, JS
操作 Virtual DOM
,最後再應用到真正的 DOM
上。
難點-演算法實現
步驟一:用 JS
物件模擬虛擬 DOM
樹
用 JavaScript
來表示一個 DOM
節點,則需要記錄它的節點型別、屬性、子節點:
element.js
function Element (tagName, props, children) { this.tagName = tagName this.props = props this.children = children } module.exports = function (tagName, props, children) { return new Element(tagName, props, children) }
上面的DOM結構可以表示為:
var el = require('./element') var ul = el('ul', {id: 'list'}, [ el('li', {class: 'item'}, ['Item 1']), el('li', {class: 'item'}, ['Item 2']), el('li', {class: 'item'}, ['Item 3']) ])
現在 ul
只是一個 JavaScript
物件表示的 DOM
結構,頁面上並沒有這個結構。可以根據這個 ul
構建真正的 <ul>
:
Element.prototype.render = function () { var el = document.createElement(this.tagName) // 根據tagName構建 var props = this.props for (var propName in props) { // 設定節點的DOM屬性 var propValue = props[propName] el.setAttribute(propName, propValue) } var children = this.children || [] children.forEach(function (child) { var childEl = (child instanceof Element) ? child.render() // 如果子節點也是虛擬DOM,遞迴構建DOM節點 : document.createTextNode(child) // 如果字串,只構建文字節點 el.appendChild(childEl) }) return el }
render
方法會根據 tagName
構建一個真正的 DOM
節點,然後設定這個節點的屬性,最後遞迴地把自己的子節點也構建起來。所以需要:
var ulRoot = ul.render() document.body.appendChild(ulRoot)
上面的 ulRoot
是真正的 DOM
節點,把它塞進文件中,這樣 body
裡面就有了真正的 <ul>
的DOM結構:
<ul id='list'> <li class='item'>Item 1</li> <li class='item'>Item 2</li> <li class='item'>Item 3</li> </ul>
步驟二:比較兩棵虛擬DOM樹的差異
比較兩棵 DOM
樹的差異是 Virtual DOM
演算法最核心的部分,就是 diff
演算法。兩棵樹的完全 diff
演算法是一個時間複雜度為 O(n^3)
的問題。但在前端中,很少會跨越層級地移動 DOM
元素。所以 Virtual DOM
只會對同一層級的元素進行對比:
上面的 div
只會和同一層級的 div
對比,第二層級的只會跟第二層級對比。這樣演算法複雜度就可以達到 O(n)
。
a.深度優先遍歷,記錄差異
在實際的程式碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每個節點都會有一個唯一的標記:
在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的樹進行對比。如果有差異的話就記錄到一個物件裡面。
// diff 函式,對比兩棵樹 function diff (oldTree, newTree) { var index = 0 // 當前節點的標誌 var patches = {} // 用來記錄每個節點差異的物件 dfsWalk(oldTree, newTree, index, patches) return patches } // 對兩棵樹進行深度優先遍歷 function dfsWalk (oldNode, newNode, index, patches) { // 對比oldNode和newNode的不同,記錄下來 patches[index] = [...] diffChildren(oldNode.children, newNode.children, index, patches) } // 遍歷子節點 function diffChildren (oldChildren, newChildren, index, patches) { var leftNode = null var currentNodeIndex = index oldChildren.forEach(function (child, i) { var newChild = newChildren[i] currentNodeIndex = (leftNode && leftNode.count) // 計算節點的標識 ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1 dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍歷子節點 leftNode = child }) }
例如,上面的div和新的div有差異,當前的標記是 0
,那麼:
patches[0] = [{difference}, {difference}, ...] // 用陣列儲存新舊節點的不同
同理 p
是 patches[1]
, ul
是 patches[3]
,以此類推
b.差異型別
對 DOM
操作會有的差異:
- 替換掉原來的節點,例如把上面的
div
換成了section
- 移動、刪除、新增子節點,例如上面的
div
的子節點,把p
和ul
順序互換 - 修改了節點的屬性
- 對於文字節點,文字內容可能會改變。例如修改上面的文字節點
2
內容為Virtual DOM2
所以定義了幾種差異型別:
var REPLACE = 0 var REORDER = 1 var PROPS = 2 var TEXT = 3
對於節點的替換,判斷新舊節點的 tagName
和是不是一樣,如果不一樣就替換掉。如 div
換成 section
,記錄如下:
patches[0] = [{ type: REPALCE, node: newNode // el('section', props, children) }]
如果給 div
新增了屬性 id
為 container
,記錄如下:
patches[0] = [{ type: REPALCE, node: newNode // el('section', props, children) }, { type: PROPS, props: { id: "container" } }]
如果修改文字節點,如上面的文字節點 2
,記錄如下:
patches[2] = [{ type: TEXT, content: "Virtual DOM2" }]
c.列表對比演算法
上面如果把 div
中的子節點重新排序,看如 p
, ul
, div
的順序換成了 div
, p
, ul
。按照同層進行順序對比的話,它們都會被替換掉,這樣 DOM
開銷非常大。而實際上只需要通過節點移動就可以的了。
假設現在可以英文字母唯一得標誌每一個子節點:
舊的節點順序:
a b c d e f g h i
現在對節點進行刪除、插入、移動的操作。新增j節點,刪除e節點,移動h節點:
新的節點順序:
a b c h d f g i j
現在知道了新舊的順序,求最小的插入、刪除操作(移動可以看成是刪除和插入操作的結合)。這個問題抽象出來其實是字串的最小編輯距離問題( Edition Distance
),最常見的演算法是 Levenshtein Distance
,
通過動態規劃求解,時間複雜度為 O(M*N)
。而我們只需要優化一些常見的移動操作,犧牲一定的 DOM
操作,讓演算法時間複雜度達到線性的 O((max(M,N)))
。
獲取某個父節點的子節點的操作,就可以記錄如下:
patches[0] = [{ type: REORDER, moves: [{remove or insert}, {remove or insert}, ...] }]
由於 tagName
是可以重複的,所以不能用這個來進行對比。需要給子節點加上一盒唯一標識 key
,列表對比的時候,使用 key
進行對比,這樣就能複用舊的 DOM
樹上的節點。
通過深度優先遍歷兩棵樹,每層節點進行對比,記錄下每個節點的差異。完整的 diff
演算法訪問: https://github.com/livoras/si...
步驟三:把差異應用到真正的 DOM
樹上
因為步驟一所構建的 JavaScript
物件樹和 render
出來的真正的 DOM
樹的資訊、結構是一樣的。所以可以對那棵 DOM
樹也進行深度優先遍歷,遍歷的時候從步驟二生成的 patches
物件中找出當前遍歷的節點差異,然後進行 DOM
操作。
function patch (node, patches) { var walker = {index: 0} dfsWalk(node, walker, patches) } function dfsWalk (node, walker, patches) { var currentPatches = patches[walker.index] // 從patches拿出當前節點的差異 var len = node.childNodes ? node.childNodes.length : 0 for (var i = 0; i < len; i++) { // 深度遍歷子節點 var child = node.childNodes[i] walker.index++ dfsWalk(child, walker, patches) } if (currentPatches) { applyPatches(node, currentPatches) // 對當前節點進行DOM操作 } }
applyPatches
,根據不同型別的差異對當前節點進行 DOM
操作:
function applyPatches (node, currentPatches) { currentPatches.forEach(function (currentPatch) { switch (currentPatch.type) { case REPLACE: node.parentNode.replaceChild(currentPatch.node.render(), node) break case REORDER: reorderChildren(node, currentPatch.moves) break case PROPS: setProps(node, currentPatch.props) break case TEXT: node.textContent = currentPatch.content break default: throw new Error('Unknown patch type ' + currentPatch.type) } }) }
完整 patch
程式碼訪問: https://github.com/livoras/si...