《Vue.js 设计与实现》读书笔记 - 第10章、双端 Diff 算法
第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)
)
)
)
}