众所周知,vue3相比于vue2在diff算法这一块更新也很大,并且还引入了最长自增子序列算法。这篇文章主要会通过文字+代码注释的方式,分析一下vue3子节点我认为最复杂的一部分,也就是源码中的patchKeyedChildren方法,是如何通过diff算法更新的。对于最长自增子序列算法,我不会在这篇文章中详细解释,但是会根据我自己的理解,讲解一下这个算法怎么应用在dom更新上。如果读者想深入了解最长自增子序列算法,可以阅读我的另外一篇文章。
patchKeyedChildren的方法在@runtime-core/src/renderer.ts中,如果不想看我文章中的代码节选的话,可以打开源码对照。源码中patchKeyedChildren分成了5个部分,所以我也会拆分成5部分进行分析。
先通过注释看看函数参数和声明的变量
const patchKeyedChildren = (
c1: VNode[], // 旧节点集合
c2: VNodeArrayChildren, // 新节点集合
container: RendererElement, // 父容器
parentAnchor: RendererNode | null, // 锚点
parentComponent: ComponentInternalInstance | null, // 以下参数和diff无关
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length // 新子节点长度
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index
- 这一步和vue2 diff一样,先从两个子节点的头索引逐步向下👇 比较,直到两个节点不是同一个节点的时候break。从注释可以看出,指针会一直下移通过a、b,然后发现c和d不是同个节点,于是退出这一次的while循环。
// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i])
: normalizeVNode(c2[i]));
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
}
else {
break;
}
i++;
- 这一步还是和vue2 diff一样,从两个子节点的尾索引逐步向上👆 比较。
// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2])
: normalizeVNode(c2[e2]));
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
}
else {
break;
}
e1--;
e2--;
}
- 完成了头头/尾尾比较后,此时会判断一下,因为这时候可能新节点遍历完了剩下老节点,或者老节点遍历完了剩下新节点。第3⃣️ 步判断的是剩下新节点的情况,这时候patch第一个参数是null,直接把新节点挂载上去就可以了。
// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
while (i <= e2) {
patch(null, (c2[i] = optimized
? cloneIfMounted(c2[i])
: normalizeVNode(c2[i])), container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
i++;
}
}
}
- 这一步判断的就是剩余老节点的情况,处理方式非常简单,把剩余的老节点直接移除即可。
// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true);
i++;
}
}
- 到最后一步了,这一步也是vue3变化最大的一步。先来看看它是处理什么情况的
// a b [c f g h] x y
// a b [f h g c] x y
这里可以看出,经过头头尾尾移动指针缩窄范围后,剩下[]里面的四个元素还要对比,如果是vue,是会新后旧前/新前旧后再次移动指针。而vue3对这一块又细分成了三部分,先来看看声明的变量
const s1 = i; // prev starting index
const s2 = i; // next starting index
5.1 先声明了一个keyToNewIndexMap,先看看这个map的结构便于阅读,就是新节点的key和index的映射。
//[c f g h] old
//[f h g c] new
0: {"f" => 0}
1: {"h" => 1}
2: {"g" => 2}
3: {"c" => 3}
// 5.1 build key:index map for newChildren
const keyToNewIndexMap = new Map();
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i])
: normalizeVNode(c2[i]));
if (nextChild.key != null) {
// 熟悉的警告⚠️
if ((process.env.NODE_ENV !== 'production') && keyToNewIndexMap.has(nextChild.key)) {
warn(`Duplicate keys found during update:`, JSON.stringify(nextChild.key), `Make sure keys are unique.`);
}
keyToNewIndexMap.set(nextChild.key, i);
}
}
5.2 这一块的代码会有点多,主要就是判断了一下是否需要移动子节点,并且声明了一个数组,记录了新节点在旧节点数组的位置,如果子节点需要移动,那这个数组就可以为后续服务。我们可以先结合注释来看,举个例子🌰
// a b [c f g h] x y
// a b [f h g c] x y
// s1 = s2 = 头指针位置 这里=2 指向新节点[]第一个元素
// e2 = 尾指位置 这里=5 指向新节点[]最后一个元素
let j
// 记录已经patch过的数量
let patched = 0
// 需要patched的总数量 所以这里就是新节点[]里面的长度=4
const toBePatched = e2 - s2 + 1
// 是否需要移动 稍后会举例说明什么情况是不需要移动的
let moved = false
// 用来判断是否需要移动 也会举例说明
let maxNewIndexSoFar = 0
// 这个newIndexToOldIndexMap初始化长这样 [0,0,0,0]
// 用来记录新节点在旧节点[]的位置
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 开始循环旧的节点
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
// 每patch一个新节点 patched++ 这里说明新节点已经patch完了
if (patched >= toBePatched) {
// 所以直接把旧节点移除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
// 根据key 从新节点里面获取对应的索引
// 比如旧节点的c在第一位 新节点为[f h g c] 获取到的nexIndex就是3
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 没有key的情况 略
//......
}
if (newIndex === undefined) {
// 旧节点在新节点的map中找不到 直接删除
// 比如 x[c f]y x[f]y c就会删除
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 找到了 记录一下 注意这里i+1了 也就是
// [c f g h] [f h g c]生成的newIndexToOldIndexMap是
// [2,4,3,1] 再次注意⚠️ +1了
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 这里稍后详解 可以看到是这里操作了move
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
这里还是需要举例说明一下maxNewIndexSoFar和move的左右
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// maxNewIndexSoFar 默认=0 maxNewIndexSoFar其实就是记录上一个newIndex
// 先来看看不需要移动的场景 xy[ab]z x[ab]
// nexIndex是新节点在旧节点[]的位置 可以看出 遍历旧节点[]的时候
// 如果节点顺序不变 那么nexIndex总是递增的 a是0 b是1 所以每一次循环 newIndex总是
// 大于上一个的newIndex
// 如果是 xy[ab]z x[ba]
// 遍历到旧节点b的时候 newIndex是0,但是上一次newIndex也就是maxNewIndexSoFar是1
// 所以move改为true
5.3 上面步骤判断了新子节点是否需要move,那么这一步就是决定新子节点怎么样move
//🌰
// [c f g h] old
// [f h g c] new
// 详细逻辑需要看另外一篇文章 开头又注明 或者通过其他文章了解
// 这里返回[0,2] 注意这是索引 也就是[f g]是最长自增子序列
const increasingNewIndexSequence = moved
// getSequence就是获取最长自增子序列
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
// 最长自增子序列的长度
j = increasingNewIndexSequence.length - 1
// 从后向前开始遍历
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
// 新节点
const nextChild = c2[nextIndex] as VNode
// parentAnchor暂时不清楚是什么逻辑生成 但是如果当前节点不是最后一个节点
// 就用不上parentAnchor anchor取的则是当前节点的下一个节点
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// newIndexToOldIndexMap默认填充了0 如果到这里还是0 说明新节点不在旧节点里面
// 直接新增
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
//需要移动
// 1. j<0 说明increasingNewIndexSequence遍历完了 还有剩余的新节点
// 2. i 不在increasingNewIndexSequence里面 i是需要移动的
// 3. 如果当前节点在increasingNewIndexSequence里面,那么不做任何操作
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
举例说明
// [c f g h] old
// [f h g c] new
// increasingNewIndexSequence = [0,2]
我们肉眼观看可知,旧节点的fg不需要移动,就可以实现新节点的位置,这就是最长自增子序列的作用,实现dom的最小移动次数。要移动的,只有gc两个节点。
// 第一次移动 拿到的是c节点 因为c节点后面没有节点 所以参考节点是parentAnchor
// 实际效果是c节点插入到了父容器最后 页面变成 [f g h c]
//第二次移动 拿到g节点 在increasingNewIndexSequence里面,什么也不干
// 第三次移动 拿到h节点 参考节点拿当前节点后一个 就是g节点
// move方法其实就是insertBefore 所以h节点移动到了g节点前面
// 页面变成 [f h g c ]
// 同第二次移动 什么也不干 页面还是 [f h g c] 和新节点vnode顺序对应上了
总体感觉,vue3 diff算法较难理解的点在最长自增子序列的计算,并且应用在dom移动上,还有就是是否需要move的判断。源码阅读体验还是比vue2n个if else清晰直观,并且源码里面也有对应场景的注释。 最后总结一下:
- 头头比较,目的是向下移动指针缩小diff范围
- 尾尾比较,目的同上
- 经过1、2步骤后,剩余了新节点就直接插入新节点
- 经过1、2步骤后,剩余了老节点就直接删除新节点
- 以上场景都不符合,则进行:
- 新子节点建立 key 与索引的映射关系,keyToNewIndexMap
- 判断新子节点是否需要移动,并且找到新子节点在旧子节点中的位置 newIndexToOldIndexMap
- 遍历剩余新子节点,如果不在newIndexToOldIndexMap里面则新增,如果在并且需要move的话,根据获取到最长自增子序列进行移动。