第10章、雙端 Diff 算法
10.1 雙端比較的原理
上一章的移動算法並不是最優的,比如我們把 ABC
移動爲 CAB
,如下
A C
B --> A
C B
按照上一章的算法,我們遍歷新的數組,然後定下第一個元素 C
的位置後,後面的 AB
都需要被移動。但是顯而易見的,我們其實可以只移動 C
移動一次即可。
而使用雙端 Diff 就是記錄新舊兩個子數組的端點,然後 新頭節點-舊頭結點、新尾結點-舊尾結點、舊頭結點-新尾結點、舊尾結點-新頭節點,這樣四種組合依次去比較,直到找到匹配的元素,然後根據新節點的位置把對應的舊節點移動。
function patchChildren(n1, n2, container) {
// ... 其他邏輯省略
if (Array.isArray(n2.children)) {
// 如果新子元素是一組節點
if (Array.isArray(n1.children)) {
patchKeyedChildren(n1, n2, container)
}
}
}
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四個索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四個索引值對應的 vnode
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
// 循環執行四個判斷條件
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 頭部相同 不用移動節點 只需要patch打補丁
patch(oldStartVNode, newStartVNode, container)
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
// 尾部相同 不用移動節點 只需要patch打補丁
patch(oldEndVNode, newEndVNode, container)
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 如果舊頭結點和新尾結點key相同 先patch 再把節點移到尾部
patch(oldStartVNode, newEndVNode, container)
insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
// 如果新的頭結點和舊尾結點key相同 先patch 再把節點移到頭部
patch(oldEndVNode, newStartVNode, container)
insert(oldEndVNode.el, container, oldStartVNode.el)
// 移動索引值
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
}
}
10.2 雙端比較的優勢
使用了上述的雙端 Diff,在大部分情況下可以少移動一些節點。
10.3 非理想狀況下的處理方式
理想就是說每次四種條件都有一種能夠命中,實際上可能全部沒有命中,比如:
新 舊
2 1
4 <-- 2
1 3
3 4
這種情況時我們就處理新節點中的第一個節點。首先在舊數組中找到 key
相同的進行 patch
沒有相同的就創建新節點,然後把該節點已移動到最前面。同時把舊節點置爲 undefined
然後注意循環到舊節點位空時要繼續前移/後移,來忽略處理過的舊節點。
// 插到循環開始位置
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[++oldEndIdx]
}
// 忽略中間那四個判斷條件
const idxInOld = oldChildren.findIndex(
(node) => node?.key === newStartVNode.key
)
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld]
patch(vnodeToMove, newStartVNode, container)
// 插到頭部
insert(vnodeToMove.el, container, oldStartVNode.el)
// 注意處理完舊節點要在舊數組中置空
oldChildren[idxInOld] = undefined
newStartVNode = newChildren[newStartIdx++]
}
10.4 添加新元素
還是上面的情況,如果在舊節點中找不到匹配的 key,證明是新添加的元素,需要創建新節點,然後插入到頭部。
const idxInOld = oldChildren.findIndex(
(node) => node?.key === newStartVNode.key
)
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld]
patch(vnodeToMove, newStartVNode, container)
// 插到頭部
insert(vnodeToMove.el, container, oldStartVNode.el)
// 注意處理完舊節點要在舊數組中置空
oldChildren[idxInOld] = undefined
} else {
// 將新節點掛載到頭部,oldStartVNode.el 作爲錨點
patch(null, newStartVNode, container, oldStartVNode.el)
}
newStartVNode = newChildren[++newStartIdx]
然後在循環結束後,如果舊數組處理完了但是新數組還有剩餘,證明這些節點都是新增的,需要依次創建。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
for (let i = newStartIdx; i <= newEndIdx; i++) {
const anchor = newChildren[newEndIdx + 1]
? newChildren[newEndIdx + 1].el
: null
patch(null, newChildren[i], container, anchor)
}
}
注意這裏的 anchor
就是說我們雙端 diff 的時候,如果有一些節點已經放在最後了,需要放在那些節點之前。
10.5 移除不存在的元素
如果循環完舊元素有剩餘,則需要卸載。
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// ...
} else if (oldEndIdx >= oldStartIdx && newStartIdx > newEndIdx) {
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
unmount(oldChildren[i])
}
}
10.6 總結
這章整體還是比較好理解的,主要之前只知道雙端比較,現在更清楚了匹配上之後要怎麼移動。
同時這裏只判斷了 key
相同,在 Vue2 源碼中還判斷了標籤等屬性。
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}