Virtual DOM的簡單實現
了解React的同學都知道,React提供了一個高效的視圖更新機制:Virtual DOM,因為DOM天生就慢,所以操作DOM的時候要小心翼翼,稍微改動就會觸發重繪重排,大量消耗性能。
1.Virtual DOM
Virtual DOM是利用JS的原生對象來模擬DOM,既然DOM是對象,我們也可以用原生的對象來表示DOM。
var element = { tagName: ‘ul‘, // 節點標簽名 props: { class: ‘list‘ // 節點的屬性,ID,class... }, children: [ // 該節點的子節點 {tagName: ‘li‘, props: {class: ‘item‘}, children: [‘item one‘]}, {tagName: ‘li‘, props: {class: ‘item‘}, children: [‘item two‘]}, {tagName: ‘li‘, props: {class: ‘item‘}, children: [‘item three‘]} ] }
對應成相應的HTML結構為:
<ul class="list">
<li class="item">item one</li>
<li class="item">item two</li>
<li class="item">item three</li>
</ul>
但是這又有什麽用呢?不還是要操作DOM嗎?
開頭我們就說過,Virtual DOM是一個高效的視圖更新機制,沒錯,主要在更新。怎麽更新呢,那就要用到了我們之前用JS對象模擬的DOM樹了,就叫它對象樹把,我們對比前後兩棵對象樹,比較出需要更新視圖的地方,對需要更新視圖的地方才進行DOM操作,不需要更新的地方自然什麽都不做,這就避免了性能的不必要浪費,變相的提升了性能。
總之Virtual DOM算法主要包括這幾步:
初始化視圖的時候,用原生JS對象表示DOM樹,生成一個對象樹,然後根據這個對象樹來生成一個真正的DOM樹,插入到文檔中。
當狀態更新的時候,重新生成一個對象樹,將新舊兩個對象樹做對比,記錄差異。
把記錄的差異應用到第一步生成的真正的DOM樹上,視圖跟新完成
其實就是一個雙緩沖的原理,既然CPU這麽快,讀取硬盤又這麽慢,我們就在中間加一個Cache。那麽,既然DOM操作也慢,我們們就可以在JS和DOM之間也加一個Cache,這個Cache就是我們的Virtual DOM了。
其實說白了Virtual DOM的原理就是只更新需要更新的地方,其他的一概不管。
2.用對象樹表示DOM樹
用JS對象表示DOM節點還是比較容易的,我們這需要記錄DOM節點的節點類型、屬性、還有子節點就好了。
class objectTree {
constructor (tagName, props, children) {
this.tagName = tagName
this.props = props
this.children = children
}
}
我們可以通過這種方式創建一個對象樹:
var ul = new objectTree(‘ul‘, {id: ‘list‘}, [
createObjectTree(‘li‘, {class: ‘item‘}, [‘Item 1‘]),
createObjectTree(‘li‘, {class: ‘item‘}, [‘Item 2‘]),
createObjectTree(‘li‘, {class: ‘item‘}, [‘Item 3‘])
])
對象樹存在一個render方法來將對象樹轉換成真正的DOM樹:
objectTree.prototype.render = function () {
var elm = document.createElement(this.tagName)
var props = this.props
// 設置DOM節點的屬性
for (var key in props) {
elm.setAttribute(key, props[key])
}
var children = this.children || []
children.forEach((child) => {
// 如果子節點也是對象樹,則遞歸渲染,否則就是文本節點
var childElm = (child instanceof objectTree) ? child.render() : document.createTextNode(child)
elm.appendChild(childElm)
})
return elm
}
我們就可以將生成好的DOM樹插入到文檔裏了
var ul = new objectTree(‘ul‘, {id: ‘list‘}, [
new objectTree(‘li‘, {class: ‘item‘}, [‘Item 1‘]),
new objectTree(‘li‘, {class: ‘item‘}, [‘Item 2‘]),
new objectTree(‘li‘, {class: ‘item‘}, [‘Item 3‘])
])
console.log(ul)
document.body.appendChild(ul.render())
我們生成的DOM已經添加到文檔裏了
3.比較兩個對象樹的差異
所謂Virtual DOM的diff算法,就是比較兩個對象樹的差異,也正是Virtual DOM的核心。
傳統的比較兩棵樹差異的算法,時間復雜度是O(n^3),大量操作DOM的時候肯定是接受不了的。所以React做了妥協,React結合WEB界面的特點,做了兩個簡單的假設,使得算法的復雜度降低到了O(n)。
相同的組件產生相似的DOM樹,不同的組件產生不同的DOM樹。
對於同一層次的一組子節點,它們可以通過唯一的id進行區分。
不同節點類型的比較
不同節點類型分為兩種情況:
節點類型不同。
節點類型相同,但是屬性不同。
先看第一種情況,如果是我們會怎麽做呢?肯定是直接刪除老的節點,然後在老節點的位置上將新節點插入。React也和我們的想法一樣,也符合我們對真實DOM操作的理解。
如果將老節點刪除,那麽老節點的子節點也同時被刪除,並且子節點也不會參與後續的比較。這也是算法復雜度能降低到O(n)的原因之一。
既然節點類型不同是這樣操作的,那麽組件也是一樣的邏輯了。應用第一個假設,不同組件之間有不同的DOM樹,與其花時間比較它們的DOM結構,還不如創建一個新的組件加到原來的組件上。
從不同節點的操作上我們可以推斷,React的diff算法是只對對象樹逐層比較。
逐層進行節點比較
在React中對樹的算法非常簡單,那就是對兩棵樹同一層次的節點進行比較。
有一張非常經典的圖:
React只會對相同顏色方框內的DOM節點進行比較,即同一個父節點下的所有子節點。當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。
考慮下如果有這樣的DOM結構的變化:
我們想的操作是:R.remove(A), D.append(A)
但是因為React只會對同一層次的節點進行比較,當發現新的對象樹中沒有A節點時,就會完全刪除A,同理,會新創建一個A節點作為D的子節點。實際React的操作是:A.destroy(), A = new A(), A.append(new B()), A.append(new C()), D.append(A)
由此我們可以根據React只對同一層次的節點比較可以作出的優化是:盡量不要跨層級的修改DOM。
相同節點類型的比較
剛才我們說過,相通節點類型屬性可能不同,React會對屬性進行重設,但要註意:Virtual DOM中style必須是個對象。
renderA: <div style={{color: ‘red‘}} />
renderB: <div style={{fontWeight: ‘bold‘}} />
=> [removeStyle color], [addStyle font-weight ‘bold‘]
key值的使用
我們經常在遍歷一個數組或列表需要一個標識一個唯一的key,這個key是幹什麽的呢?
這是初始視圖:
我們現在想在它們中間加一個F,也就是一個insert操作。
如果每個節點沒有一個唯一的key,React不能識別每個節點,那React就會將C更新成F,將D更新成C,最後在末尾插入一個D。
如果每個節點有一個唯一的key做標識,React會找到正確的位置去插入新的節點,從而提高了視圖更新的效率。
對於key我們可以給出的優化是:給每個列表元素加上一個唯一的key
4.diff算法的簡單實現
我們先對兩棵對象樹做一個深度優先的遍歷,這樣每一個節點都有一個唯一的標記:
在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。如果有差異的話就記錄到一個對象裏面。
// 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}, ...] // 用數組存儲新舊節點的不同
那我們所說的差異是什麽呢?
節點被替換
增加、刪除、移動子節點
修改了節點的屬性
若是文本節點,則文本內容可能會被改變
所以我們定義了幾種類型:
var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3
舉個例子,如果最外層的div被換成了section,則相應的記錄如下:
patches[0] = [{
type: REPALCE,
node: newNode // el(‘section‘, props, children)
}]
其他變化同理。
5.patch方法的實現
我們比較完了兩棵對象樹的差異,接下來就是將差異應用到DOM上了。這個過程有點像打補丁,所以我們叫它patch。
我們第一步構建出來的對象樹和真正的DOM樹的屬性、結構是一樣的,所以我們可以對DOM樹進行一次深度優先遍歷,遍歷的時候按著diff生成的patch對象進行patch操作,修改需要patch的地方。
我們還要根據不同的差異進行不同的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操作
}
}
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)
}
})
}
看過了別人的文章,也借鑒了別人的思想,加上自己的總結,代碼正在整理中。
Virtual DOM的簡單實現