前言
Vue通過雙向綁定來實現數據驅動視圖更新,當數據更新會觸發Dep對象notify通知所有的訂閱者Watcher對象,Watcher對象最後會調用run來執行Watcher對象的getter方法。
其中需要注意的是在掛載階段創建的一個Watcher對象的getter就是用於updateComponent,其中最主要方法就是調用Vue.prototype._update,而該實例方法主要進行調用patch函數進行節點的diff比較。
patch函數邏輯
通過閱讀Vue源碼可知,patch函數的邏輯實際上主要就是比較新舊vnode創建相關的DOM,主要邏輯分爲如下2點:
- 判斷新的vnode是否存在,不存在做相關處理
- 判斷oldVnode是否存在,不存在則意味着第一次掛載
新的vnode不存在
新的vnode不存在時,意味着當前頁面應該是空的,此時執行的邏輯有2點:
- 判斷oldVnode是否存在,存在調用其destorr生命週期銷燬掉
- 直接return退出整個patch函數
oldVnode是否存在
oldVnode是否存在決定了處理邏輯的不同,當oldVnode不存在時,即意味着第一次掛載,處理邏輯只需要:
依據當前虛擬節點vnode來生成真實DOM,即調用createElm
當oldVnode存在時,實際上需要判斷是SSR還是客戶端渲染已作進一步的處理。
var isRealElement = isDef(oldVnode.nodeType);
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
} else {
// 其他的處理邏輯,實際上主要是調用createElm創建真實DOM
}
本文關注的是patchVnode的具體邏輯,這裏是diff算法的核心邏輯。
在具體關注diff算法前,看下sameVnode的邏輯,Vue diff處理的前提是:
客戶端渲染 + 相同節點
那何謂相同節點?直接看源碼:
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
// 這裏可以暫不考慮相關邏輯
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
從上面代碼可以得到Vue判斷相同節點的邏輯:
- 虛擬節點vnode對象的key值相同
- 相同標籤 + 是否是註釋節點 + data都存在或都不存在 + 如果是input標籤必須相同
diff算法執行的前提是新舊節點必須相同
diff比較算法
diff比較算法的核心邏輯都是在patchVnode函數中,具體邏輯如下:
Vue diff算法是按層來處理每一個節點的,而這裏需要注意的邏輯就是子節點的處理,這裏是關鍵:
- 如果新舊vnode都存在子節點且不相同,會調用updateChildren來處理子節點的diff比較
- 如果僅新vnode存在子節點,調用addVnodes添加相關節點
- 如果僅舊vnode存在子節點,調用removeVnodes移除所有子節點內容
- 如果都不存在子節點但舊vnode存在文本內容而新vnode不存在文本內容,設置新vnode內容爲空字符串
updateChildren函數是實現層序比較的關鍵,而實際上層序diff的實現也是由於updateChildren內部調用了patchVnode函數,形成了遞歸調用。
updateChildren函數
該函數的邏輯實際上使用迭代來實現新舊節點數組的遍歷比較,以做到儘可能複用節點的DOM即最小化DOM的創建。
其主要邏輯可以簡述爲:
/*
- oldStartIndex、oldEndIndex:處理舊節點數組遍歷的雙指針參數
- newStartIndex、newEndIndex:處理新節點數組遍歷的雙指針參數
*/
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// patchVnode相關操作
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// patchVnode相關操作
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// patchVnode相關操作
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// patchVnode相關操作
} else {
// 非上述情況的比較邏輯
}
}
// 針對不同情況新增新元素或刪除舊元素
if (oldStartIdx > oldEndIdx) {
// 針對新節點數組元素addVnodes操作
} else if (newStartIdx > newEndIdx) {
// 針對舊節點數組元素removeVnodes
}
子組件的比較看似邏輯比較多,實際上邏輯可以分爲2類:
- 特殊邏輯:新舊首首比較、新舊尾尾比較、新首舊尾比較、新尾舊首比較
- 正常邏輯順序比較
實際上diff算法目的非常簡單:
就是找到兩個數組中相同節點值並複用它
其實現思路的核心在於:
新節點數組就是當前頁面上需要顯示的,以此爲依據來對比新舊數組
假設自己去實現的話,常規的做法可以有:
// 實現1:以新節點數組爲基礎遍歷,查找舊節點數組是否存在相同節點對象
for (let i = 0; i < newArray.length; i++) {
for (let j = 0; j < oldArray.length; j++) {
// 判斷是否相同
}
}
//
進一步優化使用雙指針方式來處理,可以減少遍歷次數:
let newStartIndex = 0;
let newEndIndex = newArray.length - 1;
let newStartVnode, newEndVnode;
for (;newStartIndex <= newEndIndex;newStartIndex++, newEndIndex--) {
newStartVnode = newArray[newStartIndex];
newEndVnode = newArray[newEndIndex];
for (let j = 0; j < oldArray.length; j++) {
// 相關比較處理
}
}
實際上這裏還是存在優化空間的,即內部循環也可以採用雙指針形式來實現,由此可以延伸到Vue diff算法的實現:新舊節點數組都採用雙指針方式來遍歷,而額外邏輯是如何更加高效的找到相同節點,爲了更加高效Vue diff算法做了大概下面2點的優化:
- 每一次遍歷優先對首尾元素進行兩兩比較
- 如果首尾兩兩比較判斷不是相同節點,則會依據舊節點數組生成一個map來保存一定範圍的組件key集合,便於進一步複用相同舊節點對象
Vue Diff算法去閱讀理解上會比較抽象,而抽象的來源個人感覺是如何控制4個指針參數的變化來保證不會有比較上的遺漏。
從源碼上去了解4個下標參數有如下5點的處理:
- sameVnode(oldStartVnode, newStartVnode)
新節點數組首元素與舊節點數組首元素比較,如果元素相同此時只處理newStartIndex和oldStartIndex,都是遞增操作
- sameVnode(oldEndVnode, newEndVnode)
新節點數組尾元素與舊節點數組尾元素比較,如果元素相同此時只處理newEndIndex和oldEndIndex,都是遞減操作
- sameVnode(oldStartVnode, newEndVnode))
新節點數組尾元素與舊節點數組首元素比較,如果元素相同此時newEndIndex遞減,oldStartIndex遞增
- sameVnode(oldEndVnode, newStartVnode))
新節點數組首元素與舊節點數組尾元素比較,如果元素相同此時oldEndIndex遞減,newStartIndex遞增
- 正常邏輯的處理
首尾元素比較沒有相同,則按照正常的比較邏輯,以當前newStartIndex對應的下標開始順序比較,遞增newStartIndex
通過上面的拆解可以有一個大概的思路了,接下來通過一個案例來描述Vue Diff算法大概的過程:
// 舊節點數組old -> 新節點數組new
[a, b, c, d, i, g] -> [b, a, a, f, d]
開始執行diff比較:
- oldStartIndex = 0,oldEndIndex = 5,newStartIndex = 0,newEndIndex = 4
此時a和b節點不滿足首尾sameVNode的比較,所以執行正常順序邏輯(這裏會查找舊節點數組是否有b來複用,如果複用b後此時需要注意有一個額外邏輯,將old數組中b值對應下標對應的值設置爲undefined),newStartIndex++,old數組變成了[a, undefined, c, d, i, g]
- oldStartIndex = 0, oldEndIndex = 5,newStartIndex = 1,newEndIndex = 4
此時是old數組中第一個元素a 和 new數組中第2個元素a比較,滿足首首相同的條件,此時oldStartIndex++,newStartIndex++
- oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 2,newEndIndex = 4
此時old數組中第2個元素b 和 new數組中第3個元素a比較,不滿足首尾的sameVnode條件,所以執行正常順序邏輯(會再次複用a,此時需要注意有一個額外邏輯,將old數組中a值對應下標對應的值設置爲undefined),此時newStartIndex++,而old數組變成了[undefined, undefined, c, d, i, g]
- oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 3,newEndIndex = 4
此時old數組中第2個元素b和new數組中第4個元素f比較,不滿足首尾sameVnode條件,執行正常順序邏輯(查找舊數組中是否有f,沒有則新創建一個節點f),newStartIndex++
- oldStartIndex = 1,oldEndIndex = 5,newStartIndex = 4,newEndIndex = 4
此時old數組中第2個元素b和new數組中第4個元素d比較,不滿足首尾的sameVnode條件,所以執行正常順序邏輯(查找舊節點數組中是否存在d,存在複用不存在則新建,存在的話會將old數組中d對應下標對應的值設置爲undefined),newStartIndex++,此時old數組變成了[undefined, undefined, c, undefined, i, g]
- 循環結束
當實例循環結束時,相應的數組下標停留在:
- oldStartIndex = 1,oldEndIndex = 5
- newStartIndex = 5,newEndIndex = 4
當迭代都執行完後此時Vue Diff算法還有一個額外的邏輯執行:
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
依據迭代結束後newStartIndex > newEndIndex,會執行舊節點數組相關節點移除動作:
上面實例迭代結後舊節點數組爲[undefined, undefined, c, undefined, i, g]
可以看到都是沒有使用的節點,執行removeVnodes函數,該函數的邏輯如下:
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
var ch = vnodes[startIdx];
// 節點存在
if (isDef(ch)) {
// 標籤的話執行銷燬邏輯
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch);
invokeDestroyHook(ch);
} else { // Text node
removeNode(ch.elm);
}
}
}
}
至此完成一層的迭代處理,而多層結構就是通過遞歸調用patchVnode來實現的,重複上面整個過程。