Vue2.0加入了Virtual Dom,Vue的Diff位于patch.js文件中,該算法來源于snabbdom,復(fù)雜度為O(n)
React的 Diff其實(shí)和Vue的 Diff 大同小異,只比較同層級節(jié)點(diǎn),不會跨層級比較,目的就是最小變動
Diff 的過程就是調(diào)用 patch 函數(shù),就像打補(bǔ)丁一樣修改真實(shí)DOM。
patch函數(shù)
// 只保留核心部分
function patch(oldVnode, vnode) {
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
const oEl = oldVnode.el
let parentEle = api.parentNode(oEl)
createEle(vnode)
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
api.removeChild(parentEle, oldVnode.el)
oldVnode = null
}
}
return vnode
}
(oldVnode, vnode) 也就是新舊兩個虛擬DOM節(jié)點(diǎn),一個完整的 vnode 都有什么屬性:
// body下的 <div id="v" class="classA"><div> 對應(yīng)的 oldVnode 就是
{
el: div // 對真實(shí)節(jié)點(diǎn)的引用,document.querySelector('#id.classA')
tagName: 'DIV', // 節(jié)點(diǎn)的標(biāo)簽
sel: 'div#v.classA' // 節(jié)點(diǎn)的選擇器
data: null, // 一個存儲節(jié)點(diǎn)屬性的對象,對應(yīng)節(jié)點(diǎn)的 el[prop] 屬性,如onclick, style
children: [], // 存儲子節(jié)點(diǎn)的數(shù)組,每個子節(jié)點(diǎn)也是 vnode 結(jié)構(gòu)
text: null, // 如果是文本節(jié)點(diǎn),對應(yīng)文本節(jié)點(diǎn)的textContent,否則為null
}
需要注意的是,屬性
el引用的是此Virtual DOM對應(yīng)的真實(shí)DOM,patch()的參數(shù)vnode中的el最初為null,因?yàn)?patch()之前它還沒有對應(yīng)的真實(shí)DOM
-
patch(oldVnode, vnode)的第一部分:
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
}
sameVnode() 的作用是看這兩個節(jié)點(diǎn)是否值得比較,代碼很簡單:
function sameVnode(oldVnode, vnode){
// 只保留核心部分
return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}
兩個 vnode 的 key 和 sel 相同才會去比較它們,比如 p和span、div.classA和div.classB 都被認(rèn)為是不同結(jié)構(gòu)而不去比較它們。
不值得比較的節(jié)點(diǎn)會進(jìn)入 else 中,過程如下:
else {
// 1. 獲取 oldVnode.el 的父節(jié)點(diǎn),parentEle 是真實(shí)DOM
const oEl = oldVnode.el
let parentEle = api.parentNode(oEl)
// 2. 為 vnode 創(chuàng)建它的真實(shí)DOM,令 vnode.el = 真實(shí)DOM
createEle(vnode)
if (parentEle !== null) {
// 3. parentEle 將新的DOM插入,移除舊的DOM
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
api.removeChild(parentEle, oldVnode.el)
oldVnode = null
}
}
當(dāng)不值得比較時,新節(jié)點(diǎn)直接把老節(jié)點(diǎn)整個替換掉了
最后執(zhí)行:return vnode,返回的 vnode 與傳入時的 vnode 的區(qū)別就在于:vnode.el,之前 vnode.el=null,而現(xiàn)在 vnode.el=對應(yīng)的真實(shí)DOM
var oldVnode = patch (oldVnode, vnode)
至此完成一個patch過程。
-
patchVnode(oldVnode, vnode)
兩個節(jié)點(diǎn)值得比較時,會調(diào)用patchVnode()函數(shù)
patchVnode (oldVnode, vnode) {
// 只保留核心部分
const el = vnode.el = oldVnode.el // 引用真實(shí)DOM
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
const el = vnode.el = oldVnode.el 這是很重要的一步,讓 vnode.el 引用到當(dāng)前的真實(shí)DOM,當(dāng) el 變量修改時,vnode.el 會同步變化。
節(jié)點(diǎn)比較的5種情況
-
if (oldVnode === vnode)它們的引用一致,可以認(rèn)為沒有變化,直接return -
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text)文本節(jié)點(diǎn)的比較,需要修改時,則會調(diào)用oldVnode.textContent = vnode.text -
if (oldCh && ch && oldCh !== ch)兩個節(jié)點(diǎn)都有子節(jié)點(diǎn),且它們不一樣,則調(diào)用updateChildren()函數(shù)去比較子節(jié)點(diǎn),這是Diff的核心 -
else if (ch)只有新的節(jié)點(diǎn)有子節(jié)點(diǎn),調(diào)用createEle(vnode),vnode.el已經(jīng)引用了老的DOM幾點(diǎn),createEle函數(shù)會在老的DOM節(jié)點(diǎn)上添加子節(jié)點(diǎn)。 -
else if (oldCh)新節(jié)點(diǎn)沒有子節(jié)點(diǎn),老節(jié)點(diǎn)有子節(jié)點(diǎn),則直接刪除老節(jié)點(diǎn)。
updateChildren
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0, newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { //對于vnode.key的比較,會把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) { // 比較方式一
// 頭頭比較通過,patch不同之處,如class、style
patchVnode(oldStartVnode, newStartVnode)
// 向后移動指針
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) { // 比較方式二
// 尾尾比較通過,patch不同之處
patchVnode(oldEndVnode, newEndVnode)
// 向前移動指針
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // 比較方式三
// 舊頭與新尾比較通過,patch不同之處
patchVnode(oldStartVnode, newEndVnode)
// 移除這個舊節(jié)點(diǎn),再添加到數(shù)組尾部
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // 比較方式四
// 新頭與舊尾比較通過,patch不同之處
patchVnode(oldEndVnode, newStartVnode)
// 移除這個舊節(jié)點(diǎn),再添加到數(shù)組頭部
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 四種比較方式都不滿足,如果設(shè)置了key,則使用 key 比較
if (oldKeyToIdx === undefined) {
// 由key生成index表,由此可見,應(yīng)該給節(jié)點(diǎn)數(shù)組設(shè)置唯一key
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
// 用新節(jié)點(diǎn)的key在 舊節(jié)點(diǎn)數(shù)組中查找,沒有找到這個節(jié)點(diǎn),則插入新節(jié)點(diǎn)
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
} else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
// key雖相同,但節(jié)點(diǎn)不同,把這個舊節(jié)點(diǎn)替換成新節(jié)點(diǎn)
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
} else {
// 兩個節(jié)點(diǎn)相同,則patch不同之處
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
// 移除這個舊節(jié)點(diǎn),重新插入更新后的節(jié)點(diǎn)
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
// 檢查新舊節(jié)點(diǎn)是否還有剩余
if (oldStartIdx > oldEndIdx) {
// 舊節(jié)點(diǎn)數(shù)組遍歷完了,則直接插入剩下的新節(jié)點(diǎn)
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {
// 新節(jié)點(diǎn)遍歷完了,那么直接移除剩下的舊節(jié)點(diǎn)
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
代碼很密集,用圖描述這個過程:

只有在
oldCh和newCh都存在的情況下才會執(zhí)行updateChildren(),可知updateChildren()進(jìn)行的是同層級下的Children的更新比較,也就是傳說中的Diff了。
一系列變量
- 舊節(jié)點(diǎn)數(shù)組
oldCh,新節(jié)點(diǎn)數(shù)組newCh -
oldStartIdx、oldEndIdx分別指向oldCh頭部和尾部,對應(yīng)元素oldStartVnode、oldEndVnode -
newStartIdx、newEndIdx分別指向newCh頭部和尾部,對應(yīng)元素newStartVnode、newEndVnode -
oldKeyToIdx是一個Map,其key就是v-bind:key的值,value對應(yīng)的就是當(dāng)前vnode
過程可以概況為:
頭頭比較、尾尾比較,頭尾比較,尾頭比較,如果 4 種比較都不匹配,但設(shè)置了key,則會用key進(jìn)行比較。
具體的Diff分析
Vue2在比較過程中會直接操作DOM,相比之下,React把Diff和DOM操作分為兩個階段,DOM操作在提交階段。
Diff的遍歷過程中,只要是對DOM的操作都會調(diào)用 api.insertBefore(),api.insertBefore()只是原生insertBefore()的簡單封裝。
官方文檔:
parentElement.insertBefore(newElement, referenceElement),如果referenceElement為null,則newElement將被插入到子節(jié)點(diǎn)的末尾;如果newElement已經(jīng)存在于DOM樹中,則首先會從DOM樹中移除。
- 對于頭頭比較
sameVnode(oldStartVnode, newStartVnode)和尾尾比較sameVnode(oldEndVnode, newEndVnode),為true時是不需要對移動DOM的,只是更新節(jié)點(diǎn)(patchVnode)。 -
設(shè)置
key (v-bind:key="xxx")與不設(shè)置key的區(qū)別:- 不設(shè)
key(undefined),newCh和oldCh只會進(jìn)行頭尾兩端的相互比較;而且sameVnode()在判斷兩個節(jié)點(diǎn)是否值得比較時,無法比較key是否相同。
在特定情況下,比如v-for的列表是10個多選框,選中哪一個則刪除哪一個,那么會發(fā)現(xiàn):刪除第一個checkbox,新的第一個checkbox會處于選中狀態(tài)! - 設(shè)置
key,如果頭尾兩端的比較都不匹配,還會從用key生成的Map對象oldKeyToIdx中查找匹配的節(jié)點(diǎn),最大化的復(fù)用節(jié)點(diǎn),提高性能。 - 不建議直接用
index作為key,其實(shí)就相當(dāng)于不加key。當(dāng)刪除/插入元素時,因?yàn)楹罄m(xù)元素的key="index"都會發(fā)生變化,導(dǎo)致它們重新渲染,無法復(fù)用,浪費(fèi)性能。
key="index"只適用于比較穩(wěn)定的狀態(tài)。
- 不設(shè)
- 當(dāng)
sameVnode(oldStartVnode, newEndVnode)值得比較時,說明oldStartVnode.el跑到oldEndVnode.el的后邊了。

- 當(dāng)
sameVnode(oldEndVnode, newStartVnode)值得比較,oldEndVnode.el跑到了oldStartVnode.el的前邊。
準(zhǔn)確的說應(yīng)該是oldEndVnode.el需要移動到oldStartVnode.el的前邊。
而實(shí)際操作是,先插入,后移除。

-
4種頭尾方式比較完后,就會去檢查舊節(jié)點(diǎn)的Map表。根據(jù)新節(jié)點(diǎn)的
key,到表中查找是否有這樣一個節(jié)點(diǎn)。- 找不到,說明這個新節(jié)點(diǎn)不存在與舊節(jié)點(diǎn)樹中,將新節(jié)點(diǎn)插入到
oldStartVnode.el的前邊。
- 找不到,說明這個新節(jié)點(diǎn)不存在與舊節(jié)點(diǎn)樹中,將新節(jié)點(diǎn)插入到

- 找到了,也要去檢查兩個節(jié)點(diǎn)是否還是同一節(jié)點(diǎn)
elmToMove.sel !== newStartVnode.sel
如果是同一節(jié)點(diǎn),則更新節(jié)點(diǎn),移動到新的位置;否則,替換這個舊節(jié)點(diǎn)。
-
while循環(huán)結(jié)束后(新、舊有一個發(fā)生了交叉),也分為兩種情況:-
oldStartIdx > oldEndIdx表示舊節(jié)點(diǎn)先遍歷完。當(dāng)然有可能newCh也剛好遍歷完,但都?xì)w為此類。
那么,直接插入剩下的新節(jié)點(diǎn)即可。
-

-
newStartIdx > newEndIdx表示新節(jié)點(diǎn)遍歷結(jié)束,那么就移除剩下的舊節(jié)點(diǎn)。

小結(jié)
- 盡量不要跨層級的修改DOM
- 設(shè)置
Key可以最大化的利用節(jié)點(diǎn) - 不要盲目相信
Diff的效率,在必要時可以手工優(yōu)化

原有的 oldCh 的順序是 A 、B、C、D、E、F、G,更新后成 ch 的順序 F、D、A、H、E、C、B、G

為了更好理解后續(xù)的 STEP,先看下相關(guān)符合標(biāo)記的說明:

STEP-1
對比 A-F --> G-G,sameVnode(oldEndVnode, newEndVnode)值得比較,DOM順序不變
- 對
G進(jìn)行patchVnode(),更新oldG.el - 移動指針:
--oldEndIdx,--newEndIdx

STEP-2
對比 A-F --> F-B --> A-B --> F-F,sameVnode(oldEndVnode, newStartVnode)值得比較
- 對
F進(jìn)行patchVnode(),更新oldF.el - 找到
oldStartVnode在DOM中的位置A,在其前面插入更新后的F - 移動指針:
--oldEndIdx,++newStartIdx

STEP-3
對比 A-D --> E-B --> A-B --> E-D,sameVnode(old, new)都不匹配,則取newStartVnode的key,即D.key, 在 oldKeyToIdx 中查找,并成功找到對應(yīng)的D
- 將
D賦值給elmToMove,圖示中為vnodeToMove - 對
D進(jìn)行patchVnode(),更新oldD.el - 將
oldCh中對應(yīng)D的vnode置空:oldCh[idxInOld] = null - 找到
oldStartVnode在DOM中的位置A,在其前面插入更新后的D - 移動指針:
++newStartIdx

STEP-4
對比 A-A,sameVnode(oldStartVnode, newStartVnode)匹配成功,進(jìn)行比較,DOM順序不變
- 將
A進(jìn)行patchVnode(),更新oldA.el - 移動指針:
++oldStartIdx,++newStartIdx

STEP-5
對比 B-H --> E-B --> B-B,sameVnode(oldStartVnode, newEndVnode)值得比較
- 將
B進(jìn)行patchVnode(),更新oldB.el - 找到
oldEndVnode - E的下一個兄弟節(jié)點(diǎn)G,在其前面(G前面、E后面)插入更新后的B - 移動指針:
++oldStartIdx,--newEndIdx

STEP-6
對比 C-H --> E-C --> C-C,sameVnode(oldStartVnode, newEndVnode)值得比較(同STEP-5)
- 將
C進(jìn)行patchVnode(),更新oldC.el - 找到
oldEndVnode - E的下一個兄弟節(jié)點(diǎn)B,在其前面(B前面、E后面)插入更新后的C - 移動指針:
++oldStartIdx,--newEndIdx

STEP-7
因?yàn)?STEP-3 中的 oldCh[idxInOld] = null,當(dāng)前獲取的 oldStartVnode = null
- 移動指針:
++oldStartIdx

STEP-8
對比 E-H --> E-E,sameVnode(oldEndVnode, newEndVnode)值得比較,DOM順序不變
- 將
E進(jìn)行patchVnode(),更新oldE.el - 移動指針:
--oldEndIdx,--newEndIdx

last
此時的 oldStartIdx > oldEndIdx,有一方發(fā)生了交叉,則退出循環(huán)。
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}
舊VirtualDOM 首先發(fā)生交叉,所以把新VirtualDOM中剩余的節(jié)點(diǎn)直接插入到oldCh中
- 找到
newEndIdx + 1 位置E --> before - 剩余的待處理部分:
newStartIdx -- newEndIdx 之間的 vnode視為新增的部分-- [ H ] - 插入到
before位置E的前面

注意點(diǎn)
- 在
Diff過程中,oldCh和newCh的位置并不會發(fā)生變化,真正進(jìn)行操作的是傳入的parentElm -- 父vnode的elm - 插入操作使用的
insertBefore(newElement, referenceElement),在指定節(jié)點(diǎn)前插入。 -
patch()中其實(shí)還有一個數(shù)組insertedVnodeQueue,涉及到組件的patch過程:組件的$mount函數(shù)之后之后并不會立即觸發(fā)組件實(shí)例的mounted鉤子,而是把當(dāng)前實(shí)例push到insertedVnodeQueue中,然后在patch的倒數(shù)第二行執(zhí)行invokeInsertHook -- 觸發(fā)所有組件實(shí)例的insert鉤子,而組件的insert鉤子函數(shù)中才會觸發(fā)組件實(shí)例的mounted鉤子。比如,在patch的過程中,patch了多個組件vnode,它們都進(jìn)行了$mount即生成DOM,但沒有立即觸發(fā)mounted,而是等整個patch完成,再逐一觸發(fā)。