實現一個簡單的虛擬demo算法
假如現在你需要寫一個像下面一樣的表格的應用程序,這個表格可以根據不同的字段進行升序或者降序的展示。
這個應用程序看起來很簡單,你可以想出好幾種不同的方式來寫。最容易想到的可能是,在你的 JavaScript 代碼裏面存儲這樣的數據:
var sortKey = "new" // 排序的字段,新增(new)、取消(cancel)、凈關註(gain)、累積(cumulate)人數
var sortType = 1 // 升序還是逆序
var data = [{...}, {...}, {..}, ..] // 表格數據
用三個字段分別存儲當前排序的字段、排序方向、還有表格數據;然後給表格頭部加點擊事件:當用戶點擊特定的字段的時候,根據上面幾個字段存儲的內容來對內容進行排序,然後用 JS 或者 jQuery 操作 DOM,更新頁面的排序狀態(表頭的那幾個箭頭表示當前排序狀態,也需要更新)和表格內容。
這樣做會導致的後果就是,隨著應用程序越來越復雜,需要在JS裏面維護的字段也越來越多,需要監聽事件和在事件回調用更新頁面的DOM操作也越來越多,應用程序會變得非常難維護。後來人們使用了 MVC、MVP 的架構模式,希望能從代碼組織方式來降低維護這種復雜應用程序的難度。但是 MVC 架構沒辦法減少你所維護的狀態,也沒有降低狀態更新你需要對頁面的更新操作(前端來說就是DOM操作),你需要操作的DOM還是需要操作,只是換了個地方。
既然狀態改變了要操作相應的DOM元素,為什麽不做一個東西可以讓視圖和狀態進行綁定,狀態變更了視圖自動變更,就不用手動更新頁面了。這就是後來人們想出了 MVVM 模式,只要在模版中聲明視圖組件是和什麽狀態進行綁定的,雙向綁定引擎就會在狀態更新的時候自動更新視圖(關於MV*模式的內容,可以看這篇介紹)。
MVVM 可以很好的降低我們維護狀態 -> 視圖的復雜程度(大大減少代碼中的視圖更新邏輯)。但是這不是唯一的辦法,還有一個非常直觀的方法,可以大大降低視圖更新的操作:一旦狀態發生了變化,就用模版引擎重新渲染整個視圖,然後用新的視圖更換掉舊的視圖。就像上面的表格,當用戶點擊的時候,還是在JS裏面更新狀態,但是頁面更新就不用手動操作 DOM 了,直接把整個表格用模版引擎重新渲染一遍,然後設置一下innerHTML
就完事了。
聽到這樣的做法,經驗豐富的你一定第一時間意識這樣的做法會導致很多的問題。最大的問題就是這樣做會很慢,因為即使一個小小的狀態變更都要重新構造整棵 DOM,性價比太低;而且這樣做的話,input
textarea
的會失去原有的焦點。最後的結論會是:對於局部的小視圖的更新,沒有問題(Backbone就是這麽幹的);但是對於大型視圖,如全局應用狀態變更的時候,需要更新頁面較多局部視圖的時候,這樣的做法不可取。
但是這裏要明白和記住這種做法,因為後面你會發現,其實 Virtual DOM 就是這麽做的,只是加了一些特別的步驟來避免了整棵 DOM 樹變更。
另外一點需要註意的就是,上面提供的幾種方法,其實都在解決同一個問題:維護狀態,更新視圖。在一般的應用當中,如果能夠很好方案來應對這個問題,那麽就幾乎降低了大部分復雜性。
3 Virtual DOM算法
DOM是很慢的。如果我們把一個簡單的div
元素的屬性都打印出來,你會看到:
而這僅僅是第一層。真正的 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樹。
之前的章節所說的,狀態變更->重新渲染整個視圖的方式可以稍微修改一下:用 JavaScript 對象表示 DOM 信息和結構,當狀態變更的時候,重新渲染這個 JavaScript 的對象結構。當然這樣做其實沒什麽卵用,因為真正的頁面其實沒有改變。
但是可以用新渲染的對象樹去和舊的樹進行對比,記錄這兩棵樹差異。記錄下來的不同就是我們需要對頁面真正的 DOM 操作,然後把它們應用在真正的 DOM 樹上,頁面就變更了。這樣就可以做到:視圖的結構確實是整個全新渲染了,但是最後操作DOM的時候確實只變更有不同的地方。
這就是所謂的 Virtual DOM 算法。包括幾個步驟:
- 用 JavaScript 對象結構表示 DOM 樹的結構;然後用這個樹構建一個真正的 DOM 樹,插到文檔當中
- 當狀態變更的時候,重新構造一棵新的對象樹。然後用新的樹和舊的樹進行比較,記錄兩棵樹差異
- 把2所記錄的差異應用到步驟1所構建的真正的DOM樹上,視圖就更新了
Virtual DOM 本質上就是在 JS 和 DOM 之間做了一個緩存。可以類比 CPU 和硬盤,既然硬盤這麽慢,我們就在它們之間加個緩存:既然 DOM 這麽慢,我們就在它們 JS 和 DOM 之間加個緩存。CPU(JS)只操作內存(Virtual DOM),最後的時候再把變更寫入硬盤(DOM)。
4 算法實現
4.1 步驟一:用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>
完整代碼可見 element.js。
4.2 步驟二:比較兩棵虛擬DOM樹的差異
正如你所預料的,比較兩棵DOM樹的差異是 Virtual DOM 算法最核心的部分,這也是所謂的 Virtual DOM 的 diff 算法。兩個樹的完全的 diff 算法是一個時間復雜度為 O(n^3) 的問題。但是在前端當中,你很少會跨越層級地移動DOM元素。所以 Virtual DOM 只會對同一個層級的元素進行對比:
上面的div
只會和同一層級的div
對比,第二層級的只會跟第二層級對比。這樣算法復雜度就可以達到 O(n)。
4.2.1 深度優先遍歷,記錄差異
在實際的代碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每個節點都會有一個唯一的標記:
在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。如果有差異的話就記錄到一個對象裏面。
// 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]
,類推。
4.2.2 差異類型
上面說的節點的差異指的是什麽呢?對 DOM 操作可能會:
- 替換掉原來的節點,例如把上面的
div
換成了section
- 移動、刪除、新增子節點,例如上面
div
的子節點,把p
和ul
順序互換 - 修改了節點的屬性
- 對於文本節點,文本內容可能會改變。例如修改上面的文本節點2內容為
Virtual DOM 2
。
所以我們定義了幾種差異類型:
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"
}]
那如果把我div
的子節點重新排序呢?例如p, ul, div
的順序換成了div, p, ul
。這個該怎麽對比?如果按照同層級進行順序對比的話,它們都會被替換掉。如p
和div
的tagName
不同,p
會被div
所替代。最終,三個節點都會被替換,這樣DOM開銷就非常大。而實際上是不需要替換節點,而只需要經過節點移動就可以達到,我們只需知道怎麽進行移動。
這牽涉到兩個列表的對比算法,需要另外起一個小節來討論。
4.2.3 列表對比算法
假設現在可以英文字母唯一地標識每一個子節點:
舊的節點順序:
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 算法代碼可見 diff.js。
4.3 步驟三:把差異應用到真正的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.js。
5 結語
Virtual DOM 算法主要是實現上面步驟的三個函數:element,diff,patch。然後就可以實際的進行使用:
// 1. 構建虛擬DOM
var tree = el(‘div‘, {‘id‘: ‘container‘}, [
el(‘h1‘, {style: ‘color: blue‘}, [‘simple virtal dom‘]),
el(‘p‘, [‘Hello, virtual-dom‘]),
el(‘ul‘, [el(‘li‘)])
])
// 2. 通過虛擬DOM構建真正的DOM
var root = tree.render()
document.body.appendChild(root)
// 3. 生成新的虛擬DOM
var newTree = el(‘div‘, {‘id‘: ‘container‘}, [
el(‘h1‘, {style: ‘color: red‘}, [‘simple virtal dom‘]),
el(‘p‘, [‘Hello, virtual-dom‘]),
el(‘ul‘, [el(‘li‘), el(‘li‘)])
])
// 4. 比較兩棵虛擬DOM樹的不同
var patches = diff(tree, newTree)
// 5. 在真正的DOM元素上應用變更
patch(root, patches)
當然這是非常粗糙的實踐,實際中還需要處理事件監聽等;生成虛擬 DOM 的時候也可以加入 JSX 語法。這些事情都做了的話,就可以構造一個簡單的ReactJS了。
實現一個簡單的虛擬demo算法