Vue 3 中当新旧两个 Vnode 节点的 children 都是 Array 时,会进入 Vue 的 Diff 环节,也就是 patchKeyedChildren 函数。
接下来我带大家过一下该方法的主要逻辑,源代码已经经过简化:
1. 准备工作:
// can be all-keyed or mixed
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index
}
- c1 是旧的节点序列, e1 指向旧序列的最后一个节点
- c2 是新的节点序列, e2 指向新序列的最后一个节点
- i 我们用来遍历使用
为了方便讲解,我假设如下序列:
prev: a b c d e f g h
next: a b e c d i g h
i = 0
e1 = 7
e2 = 7
2. 过滤相同头节点
- 通过 isSameVNodeType 会对比节点的 key 和 type 是否相同。
isSameVNodeType = (n1,n2) => n1.type === n2.type && n1.key === n2.key
-
如果相同那么代表旧节点可以复用 ,继续对比下一个节点
-
如果有不同的头节点,那么直接结束循环进入下一步流程
对比后序列如下的形式:
prev: (a b) c d e f g h
next: (a b) e c d i g h
i = 2
e1 = 7
e2 = 7
例子中对比完成后 , a b我们达成了复用,不会参与到后续的diff中
以下是源代码:
// 正序遍历
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
// 相同节点直接复用
patch( n1, n2, container )
} else {
// 不同结束循环,进入尾节点 流程比较
break
}
//继续循环对比
i++
}
3. 过滤相同的尾节点
-
通过 isSameVNodeType 会对比节点的 key 和 type 是否相同。
-
如果相同那么代表旧节点可以复用 ,e1-- 和 e2-- 继续对比下一个节点
-
如果有不同的尾节点,那么直接结束循环进入下一步流程。
对比后序列如下的形式:
prev:(a b) c d e f (g h)
next:(a b) e c d i (g h)
i = 2
e1 = 5
e2 = 5
对比完成后 , g 和 h 我们达成了复用,不会参与到后续的diff中。
以下是源代码:
// 2. 新旧子序列尾节点比较,过滤掉相同的尾节点
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
// n1.type === n2.type && n1.key === n2.key
if (isSameVNodeType(n1, n2)) {
// 相同节点复用
patch( n1, n2, container )
} else {
// 如果有不同的尾节点,直接结束循环,进入第三步
break
}
//倒序去搞
e1--
e2--
}
4. 旧序列遍历完成,mount 新节点
-
如果旧序列已经遍历完成,而新序列还未遍历完成,可以继续遍历新序列,然后 mount 新节点
旧序列可以直接复用的数据有两种情况:
-
从头节点遍历完全相同
prev: (a b)
next: (a b) c
i = 2
e1 = 1
e2 = 2
- 从尾节点遍历完全相同
prev:(a b)
next:c (a b)
i = 0
e1 = -1
e2 = 0
针对这两种情况,我们直接 mount 新节点,以下是源代码:
// 旧序列遍历完成
if (i > e1) {
// 新序列还未遍历完成
if (i <= e2) {
const nextPos = e2 + 1
while (i <= e2) {
// mount节点 n1 为 null,n2 为 VNode
patch(
null,
c2[i],
container,
)
i++
}
}
}
5. 新序列遍历完成,unmount 旧节点
那么新节点mount完成了,我们来看另一种情况:如果新序列已经遍历完,而旧序列还未遍历完,我们可以继续遍历旧序列,然后 unmount 无用节点。
数据形式也是两种情况:
- 从头节点遍历完全相同
prev: (a b) c
next: (a b)
i = 2
e1 = 2
e2 = 1
- 从尾节点遍历完全相同
prev:c (a b)
next: (a b)
i = 0
e1 = 0
e2 = -1
针对这两种情况,我们直接 unmount 移除节点,以下是源代码:
// 新序列遍历完成
else if (i > e2) {
while (i <= e1) {
unmount(c1[i])
i++
}
}
6.处理不同的子序列
经过以上处理,我们得到以下的结构:
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
接下来我们来处理中间的子序列,处理这个序列中的新增、删除、移动的情况。
6.1 生成 keyToNewIndexMap
得出旧节点在新序列中的位置,生成数组 keyToNewIndexMap
else {
// 旧子序列开始索引,从i开始记录
const s1 = i
// 新子序列开始索引,从i开始记录
const s2 = i
// 得出节点在新序列中的位置
const keyToNewIndexMap = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = c2[i]
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
6.2 新旧子序列节点的更新、多余节点的删除
建立了一个 newIndexToOldIndexMap 存储新子序列的节点的索引和旧子序列节点的索引之间的映射关系,并确定是否有移动 moved
let j
let patched = 0
const toBePatched = e2 - s2 + 1
// 用户跟踪判断是否有节点移动
let moved = false
let maxNewIndexSoFar = 0
// 这个数组用来存储新子序列中元素在旧子序列中元素的位置
const newIndexToOldIndexMap = new Array(toBePatched)
// 初始化数组,数组的每一位都是0
// 0 是一个特殊的值,如果遍历完了仍有元素的值为0,则说明这个新节点没有对应的旧节点 ,需要新建节点
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 正序遍历子序列
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
if (patched >= toBePatched) {
// 所有新的子项都已修补,因此这只能是删除
unmount(prevChild)
continue
}
let newIndex
if (prevChild.key != null) {
// 如果有key,查看该老节点在新序列中的位置
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 如果没有 key,尝试在新列表中查找相同节点的索引
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j])
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 如果newIndex没有查到 直接卸载节点
unmount(prevChild)
} else {
// 更新新子序列中的元素在旧子序列中的索引,这里加 1 偏移,是为了避免 i 为 0 的特殊情况,影响对后续最长递增子序列的求解
newIndexToOldIndexMap[newIndex - s2] = i + 1
// maxNewIndexSoFar 始终存储的是上次求值的 newIndex 如果不是一直递增,则说明有移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// 更新新旧子序列中匹配的节点
patch(
prevChild,
c2[newIndex] as VNode,
container
)
patched++
}
}
6.3 移动和挂载节点
根据 (newIndexToOldIndexMap 简称) map 生成最长递增子序列 sequence,倒序遍历 map,如果map[i]为 0 新建节点,如果不为 0 移动节点
// 仅当节点移动时才生成最长递增子序列
const sequence = moved
// getSequence 最长递增子序列算法
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = sequence.length - 1
// 倒序遍历以便我们可以以最后更新的节点作为锚点
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
// 如果newIndexToOldIndexMap取出的数为 0 那么新建节点
if (newIndexToOldIndexMap[i] === 0) {
// 挂载新的子节点
patch(
null,
nextChild,
container
)
} else if (moved) {
// 没有最长递增序列(reverse 数组的场景)
// 或者 当前的节点索引不在最长递增子序列中,需要移动节点
if (j < 0 || i !== sequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
// 倒序最长递增子序列
j--
}
}
}
}
}
以上即为 Vue 3 中 patchKeyedChildren 的主要逻辑,也是diff算法的主要逻辑,时间关系(最长递增子序列/patch/unmount/move)的代码,还未贴上,后续会继续更上的。