vue原始碼分析-diff演算法核心原理
這一節,依然是深入剖析Vue原始碼系列,上幾節內容介紹了
Virtual DOM
是Vue在渲染機制上做的優化,而渲染的核心在於資料變化時,如何高效的更新節點,這就是diff演算法。由於原始碼中關於diff
演算法部分流程複雜,直接剖析每個流程不易於理解,所以這一節我們換一個思路,參考原始碼來手動實現一個簡易版的diff
演算法。
之前講到Vue
在渲染機制的優化上,引入了Virtual DOM
的概念,利用Virtual DOM
描述一個真實的DOM
,本質上是在JS
和真實DOM
之間架起了一層緩衝層。當我們通過大量的JS
運算,並將最終結果反應到瀏覽器進行渲染時,Virtual DOM
可以將多個改動合併成一個批量的操作,從而減少 dom
Vue
中Vnode
的概念,以及建立Vnode
到渲染Vnode
再到真實DOM
的過程。如果有忘記流程的,可以參考前面的章節分析。
從render
函式到建立虛擬DOM
,再到渲染真實節點,這一過程是完整的,也是容易理解的。然而引入虛擬DOM
的核心不在這裡,而在於當資料發生變化時,如何最優化資料變動到檢視更新的過程。這一個過程才是Vnode
更新檢視的核心,也就是常說的diff
演算法。下面跟著我來實現一個簡易版的diff
演算法
8.1 建立基礎類
程式碼編寫過程會遇到很多基本型別的判斷,第一步需要先將這些方法封裝。
class Util { constructor() {} // 檢測基礎型別 _isPrimitive(value) { return (typeof value === 'string' || typeof value === 'number' || typeof value === 'symbol' || typeof value === 'boolean') } // 判斷值不為空 _isDef(v) { return v !== undefined && v !== null } } // 工具類的使用 const util = new Util()
8.2 建立Vnode
Vnode
這個類在之前章節已經分析過原始碼,本質上是用一個物件去描述一個真實的DOM
元素,簡易版關注點在於元素的tag
標籤,元素的屬性集合data
,元素的子節點children
,text
為元素的文字節點,簡單的描述類如下:
class VNode {
constructor(tag, data, children) {
this.tag = tag;
this.data = data;
this.children = children;
this.elm = ''
// text屬性用於標誌Vnode節點沒有其他子節點,只有純文字
this.text = util._isPrimitive(this.children) ? this.children : ''
}
}
8.3 模擬渲染過程
接下來需要建立另一個類模擬將render
函式轉換為Vnode
,並將Vnode
渲染為真實DOM
的過程,我們將這個類定義為Vn
,Vn
具有兩個基本的方法createVnode, createElement
, 分別實現建立虛擬Vnode
,和建立真實DOM
的過程。
8.3.1 createVnode
createVnode
模擬Vue
中render
函式的實現思路,目的是將資料轉換為虛擬的Vnode
,先看具體的使用和定義。
// index.html
<script src="diff.js">
<script>
// 建立Vnode
let createVnode = function() { let _c = vn.createVnode; return _c('div', { attrs: { id: 'test' } }, arr.map(a => _c(a.tag, {}, a.text)))}
// 元素內容結構
let arr = [{ tag: 'i', text: 2
}, { tag: 'span', text: 3
}, { tag: 'strong', text: 4
}]
</script>
// diff.js
(function(global) {
class Vn {
constructor() {}
// 建立虛擬Vnode
createVnode(tag, data, children) {
return new VNode(tag, data, children)
}
}
global.vn = new Vn()
}(this))
這是一個完整的Vnode
物件,我們已經可以用這個物件來簡單的描述一個DOM
節點,而createElement
就是將這個物件對應到真實節點的過程。最終我們希望的結果是這樣的。
Vnode物件
渲染結果
8.3.2 createElement
渲染真實DOM
的過程就是遍歷Vnode
物件,遞迴建立真實節點的過程,這個不是本文的重點,所以我們可以粗糙的實現。
class Vn {
createElement(vnode, options) {
let el = options.el;
if(!el || !document.querySelector(el)) return console.error('無法找到根節點')
let _createElement = vnode => {
const { tag, data, children } = vnode;
const ele = document.createElement(tag);
// 新增屬性
this.setAttr(ele, data);
// 簡單的文字節點,只要建立文字節點即可
if (util._isPrimitive(children)) {
const testEle = document.createTextNode(children);
ele.appendChild(testEle)
} else {
// 複雜的子節點需要遍歷子節點遞迴建立節點。
children.map(c => ele.appendChild(_createElement(c)))
}
return ele
}
document.querySelector(el).appendChild(_createElement(vnode))
}
}
8.3.3 setAttr
setAttr
是為節點設定屬性的方法,利用DOM
原生的setAttribute
為每個節點設定屬性值。
class Vn {
setAttr(el, data) {
if (!el) return
const attrs = data.attrs;
if (!attrs) return;
Object.keys(attrs).forEach(a => {
el.setAttribute(a, attrs[a]);
})
}
}
至此一個簡單的 資料 -> Virtual DOM
=> 真實DOM
的模型搭建成功,這也是資料變化、比較、更新的基礎。
8.4 diff演算法實現
更新元件的過程首先是響應式資料發生了變化,資料頻繁的修改如果直接渲染到真實DOM
上會引起整個DOM
樹的重繪和重排,頻繁的重繪和重排是極其消耗效能的。如何優化這一渲染過程,Vue
原始碼中給出了兩個具體的思路,其中一個是在介紹響應式系統時提到的將多次修改推到一個佇列中,在下一個tick
去執行檢視更新,另一個就是接下來要著重介紹的diff
演算法,將需要修改的資料進行比較,並只渲染必要的DOM
。
資料的改變最終會導致節點的改變,所以diff
演算法的核心在於在儘可能小變動的前提下找到需要更新的節點,直接呼叫原生相關DOM
方法修改檢視。不管是真實DOM
還是前面建立的Virtual DOM
,都可以理解為一顆DOM
樹,演算法比較節點不同時,只會進行同層節點的比較,不會跨層進行比較,這也大大減少了演算法複雜度。
參考 Vue面試題詳細解答
8.4.1 diffVnode
在之前的基礎上,我們實現一個思路,1秒之後資料發生改變。
// index.html
setTimeout(function() {
arr = [{ tag: 'span', text: 1 },{ tag: 'strong', text: 2 },{ tag: 'i', text: 3 },{ tag: 'i', text: 4 }]
// newVnode 表示改變後新的Vnode樹
const newVnode = createVnode();
// diffVnode會比較新舊Vnode樹,並完成檢視更新
vn.diffVnode(newVnode, preVnode);
})
diffVnode
的邏輯,會對比新舊節點的不同,並完成檢視渲染更新
class Vn {
···
diffVnode(nVnode, oVnode) {
if (!this._sameVnode(nVnode, oVnode)) {
// 直接更新根節點及所有子節點
return ***
}
this.generateElm(vonde);
this.patchVnode(nVnode, oVnode);
}
}
8.4.2 _sameVnode
新舊節點的對比是演算法的第一步,如果新舊節點的根節點不是同一個節點,則直接替換節點。這遵從上面提到的原則,只進行同層節點的比較,節點不一致,直接用新節點及其子節點替換舊節點。為了理解方便,我們假定節點相同的判斷是tag
標籤是否一致(實際原始碼要複雜)。
class Vn {
_sameVnode(n, o) {
return n.tag === o.tag;
}
}
8.4.3 generateElm
generateElm
的作用是跟蹤每個節點實際的真實節點,方便在對比虛擬節點後實時更新真實DOM
節點。雖然Vue
原始碼中做法不同,但是這不是分析diff
的重點。
class Vn {
generateElm(vnode) {
const traverseTree = (v, parentEl) => {
let children = v.children;
if(Array.isArray(children)) {
children.forEach((c, i) => {
c.elm = parentEl.childNodes[i];
traverseTree(c, c.elm)
})
}
}
traverseTree(vnode, this.el);
}
}
執行generateElm
方法後,我們可以在舊節點的Vnode
中跟蹤到每個Virtual DOM
的真實節點資訊。
8.4.4 patchVnode
patchVnode
是新舊Vnode
對比的核心方法,對比的邏輯如下。
- 節點相同,且節點除了擁有文字節點外沒有其他子節點。這種情況下直接替換文字內容。
- 新節點沒有子節點,舊節點有子節點,則刪除舊節點所有子節點。
- 舊節點沒有子節點,新節點有子節點,則用新的所有子節點去更新舊節點。
- 新舊都存在子節點。則對比子節點內容做操作。
程式碼邏輯如下:
class Vn {
patchVnode(nVnode, oVnode) {
if(nVnode.text && nVnode.text !== oVnode) {
// 當前真實dom元素
let ele = oVnode.elm
// 子節點為文字節點
ele.textContent = nVnode.text;
} else {
const oldCh = oVnode.children;
const newCh = nVnode.children;
// 新舊節點都存在。對比子節點
if (util._isDef(oldCh) && util._isDef(newCh)) {
this.updateChildren(ele, newCh, oldCh)
} else if (util._isDef(oldCh)) {
// 新節點沒有子節點
} else {
// 老節點沒有子節點
}
}
}
}
上述例子在patchVnode
過程中,新舊子節點都存在,所以會走updateChildren
分支。
8.4.5 updateChildren
子節點的對比,我們通過文字和畫圖的形式分析,通過圖解的形式可以很清晰看到diff
演算法的巧妙之處。
大致邏輯是:
- 舊節點的起始位置為
oldStartIndex
,截至位置為oldEndIndex
,新節點的起始位置為newStartIndex
,截至位置為newEndIndex
。 - 新舊
children
的起始位置的元素兩兩對比,順序是newStartVnode, oldStartVnode
;newEndVnode, oldEndVnode
;newEndVnode, oldStartVnode
;newStartIndex, oldEndIndex
-
newStartVnode, oldStartVnode
節點相同,執行一次patchVnode
過程,也就是遞迴對比相應子節點,並替換節點的過程。oldStartIndex,newStartIndex
都像右移動一位。 -
newEndVnode, oldEndVnode
節點相同,執行一次patchVnode
過程,遞迴對比相應子節點,並替換節點。oldEndIndex, newEndIndex
都像左移動一位。 -
newEndVnode, oldStartVnode
節點相同,執行一次patchVnode
過程,並將舊的oldStartVnode
移動到尾部,oldStartIndex
右移一味,newEndIndex
左移一位。 -
newStartIndex, oldEndIndex
節點相同,執行一次patchVnode
過程,並將舊的oldEndVnode
移動到頭部,oldEndIndex
左移一味,newStartIndex
右移一位。 - 四種組合都不相同,則會搜尋舊節點所有子節點,找到將這個舊節點和
newStartVnode
執行patchVnode
過程。 - 不斷對比的過程使得
oldStartIndex
不斷逼近oldEndIndex
,newStartIndex
不斷逼近newEndIndex
。當oldEndIndex <= oldStartIndex
說明舊節點已經遍歷完了,此時只要批量增加新節點即可。當newEndIndex <= newStartIndex
說明舊節點還有剩下,此時只要批量刪除舊節點即可。
結合前面的例子:
第一步:
第二步:
第三步:
第三步:
第四步:
根據這些步驟,程式碼實現如下:
class Vn {
updateChildren(el, newCh, oldCh) {
// 新children開始標誌
let newStartIndex = 0;
// 舊children開始標誌
let oldStartIndex = 0;
// 新children結束標誌
let newEndIndex = newCh.length - 1;
// 舊children結束標誌
let oldEndIndex = oldCh.length - 1;
let oldKeyToId;
let idxInOld;
let newStartVnode = newCh[newStartIndex];
let oldStartVnode = oldCh[oldStartIndex];
let newEndVnode = newCh[newEndIndex];
let oldEndVnode = oldCh[oldEndIndex];
// 遍歷結束條件
while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
// 新children開始節點和舊開始節點相同
if (this._sameVnode(newStartVnode, oldStartVnode)) {
this.patchVnode(newCh[newStartIndex], oldCh[oldStartIndex]);
newStartVnode = newCh[++newStartIndex];
oldStartVnode = oldCh[++oldStartIndex]
} else if (this._sameVnode(newEndVnode, oldEndVnode)) {
// 新childre結束節點和舊結束節點相同
this.patchVnode(newCh[newEndIndex], oldCh[oldEndIndex])
oldEndVnode = oldCh[--oldEndIndex];
newEndVnode = newCh[--newEndIndex]
} else if (this._sameVnode(newEndVnode, oldStartVnode)) {
// 新childre結束節點和舊開始節點相同
this.patchVnode(newCh[newEndIndex], oldCh[oldStartIndex])
// 舊的oldStartVnode移動到尾部
el.insertBefore(oldCh[oldStartIndex].elm, null);
oldStartVnode = oldCh[++oldStartIndex];
newEndVnode = newCh[--newEndIndex];
} else if (this._sameVnode(newStartVnode, oldEndVnode)) {
// 新children開始節點和舊結束節點相同
this.patchVnode(newCh[newStartIndex], oldCh[oldEndIndex]);
el.insertBefore(oldCh[oldEndIndex].elm, oldCh[oldStartIndex].elm);
oldEndVnode = oldCh[--oldEndIndex];
newStartVnode = newCh[++newStartIndex];
} else {
// 都不符合的處理,查詢新節點中與對比舊節點相同的vnode
this.findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
}
}
// 新節點比舊節點多,批量增加節點
if(oldEndIndex <= oldStartIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 批量增加節點
this.createElm(oldCh[oldEndIndex].elm, newCh[i])
}
}
}
createElm(el, vnode) {
let tag = vnode.tag;
const ele = document.createElement(tag);
this._setAttrs(ele, vnode.data);
const testEle = document.createTextNode(vnode.children);
ele.appendChild(testEle)
el.parentNode.insertBefore(ele, el.nextSibling)
}
// 查詢匹配值
findIdxInOld(newStartVnode, oldCh, start, end) {
for (var i = start; i < end; i++) {
var c = oldCh[i];
if (util.isDef(c) && this.sameVnode(newStartVnode, c)) { return i }
}
}
}
8.5 diff演算法優化
前面有個分支,當四種比較節點都找不到匹配時,會呼叫findIdxInOld
找到舊節點中和新的比較節點一致的節點。節點搜尋在數量級較大時是緩慢的。檢視Vue
的原始碼,發現它在這一個環節做了優化,也就是我們經常在編寫列表時被要求加入的唯一屬性key,有了這個唯一的標誌位,我們可以對舊節點建立簡單的字典查詢,只要有key
值便可以方便的搜尋到符合要求的舊節點。修改程式碼:
class Vn {
updateChildren() {
···
} else {
// 都不符合的處理,查詢新節點中與對比舊節點相同的vnode
if (!oldKeyToId) oldKeyToId = this.createKeyMap(oldCh, oldStartIndex, oldEndIndex);
idxInOld = util._isDef(newStartVnode.key) ? oldKeyToId[newStartVnode.key] : this.findIdxInOld(newStartVnode, oldCh, oldStartIndex, oldEndIndex);
// 後續操作
}
}
// 建立字典
createKeyMap(oldCh, start, old) {
const map = {};
for(let i = start; i < old; i++) {
if(oldCh.key) map[key] = i;
}
return map;
}
}
8.6 問題思考
最後我們思考一個問題,Virtual DOM
的重繪效能真的比單純的innerHTML
要好嗎,其實並不是這樣的,作者的解釋
innerHTML: render html string O(template size) +
重新建立所有DOM
元素O(DOM size)
Virtual DOM: render Virtual DOM + diff O(template size) +
必要的DOM
更新O(DOM change)
Virtual DOM render + diff
顯然比渲染 html 字串要慢,但是!它依然是純 js 層面的計算,比起後面的DOM
操作來說,依然便宜了太多。可以看到,innerHTML
的總計算量不管是js
計算還是DOM
操作都是和整個介面的大小相關,但Virtual DOM
的計算量裡面,只有js
計算和介面大小相關,DOM 操作是和資料的變動量相關的。