這一節,依然是深入剖析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
樹,演算法比較節點不同時,只會進行同層節點的比較,不會跨層進行比較,這也大大減少了演算法複雜度。
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 操作是和資料的變動量相關的。
- 深入剖析Vue原始碼 - 選項合併(上)
- 深入剖析Vue原始碼 - 選項合併(下)
- 深入剖析Vue原始碼 - 資料代理,關聯子父元件
- 深入剖析Vue原始碼 - 例項掛載,編譯流程
- 深入剖析Vue原始碼 - 完整渲染過程
- 深入剖析Vue原始碼 - 元件基礎
- 深入剖析Vue原始碼 - 元件進階
- 深入剖析Vue原始碼 - 響應式系統構建(上)
- 深入剖析Vue原始碼 - 響應式系統構建(中)
- 深入剖析Vue原始碼 - 響應式系統構建(下)
- 深入剖析Vue原始碼 - 來,跟我一起實現diff演算法!
- 深入剖析Vue原始碼 - 揭祕Vue的事件機制
- 深入剖析Vue原始碼 - Vue插槽,你想了解的都在這裡!
- 深入剖析Vue原始碼 - 你瞭解v-model的語法糖嗎?
- 深入剖析Vue原始碼 - Vue動態元件的概念,你會亂嗎?
- 徹底搞懂Vue中keep-alive的魔法(上)
- 徹底搞懂Vue中keep-alive的魔法(下)