Vue3 源码解读之patch算法(二)
在《Vue3 源码解读之patch算法(一) 》一文中,我们对 patch 过程中的文本节点、注释节点、静态节点、Fragment节点、Element类型的节点、Component 组件、Teleport 组件、Suspense 异步组件等处理过程做了介绍。
在本文中,我们将会深入解读 patch 的核心 Diff 算法。我们从 patchChildren 函数开始分析。
patchChildren 执行 Diff,更新节点
// packages/runtime-core/src/renderer.ts
const patchChildren: PatchChildrenFn = (
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized = false
) => {
// 旧节点的子节点
const c1 = n1 && n1.children
// 旧节点的 shapeFlag
const prevShapeFlag = n1 ? n1.shapeFlag : 0
// 新节点的子节点
const c2 = n2.children
const { patchFlag, shapeFlag } = n2
// fast path
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// 判断新旧子节点数组是否都是 keyed 或者混合的。如果存在 patchFlag,则说明子节点数组都是数组类型的。
// 然后调用 patchKeyedChildren 函数对子节点数组进行 diff 并更新 DOM。
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 新的子节点没有 key 属性
patchUnkeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
return
}
}
// 新子节点有 3 中可能:文本、数组、或没有 children
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 文本节点的快速 diff
// text children fast path
// 旧子节点是数组,则卸载旧子节点
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
}
// 子节点是文本节点,新旧文本不一致时,直接更新
if (c2 !== c1) {
hostSetElementText(container, c2 as string)
}
} else {
// 子节点是数组时,对子节点进行 diff
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧子节点是数组
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新子节点也是数组,那就对两组子节点进行 diff
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// no new children, just unmount old
// 旧子节点是数组时,没有新的子节点,那就删除旧子节点
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
}
} else {
// 旧子节点是文本或者 null
// 新子节点是数组或者为null
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '')
}
// 新子节点是数组,则挂载新子节点
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
c2 as VNodeArrayChildren,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
}
}
从 patchChildren 函数的源码中我们可以知道,Vue 在比较新旧两组子节点时:
- 如果新的子节点没有
key属性,那么会调用patchUnkeyedChildren函数来对新旧两组子节点进行Diff比较。 - 如果新的一组子节点有
key属性,那么会调用patchKeyedChildren函数来对新旧两组子节点进行Diff比较。
那么,有 key 属性和没有 key 属性的 Diff 比较有什么区别呢?下面,我们分别来看看这两种 Diff 算法。
无 key 属性的 Diff 比较
没有 key 属性的 Diff 比较,会调用 patchUnkeyedChildren 函数来比较两组子节点。该函数的代码如下所示:
// packages/runtime-core/src/renderer.ts
// 没有 key 标识的子节点的 patch 过程,即 diff 过程
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
// 旧子节点
c1 = c1 || EMPTY_ARR
// 新子节点
c2 = c2 || EMPTY_ARR
// 旧的一组子节点的长度
const oldLength = c1.length
// 新的一组子节点的长度
const newLength = c2.length
// 两组子节点的公共长度,即两者中较短的那一组子节点的长度
const commonLength = Math.min(oldLength, newLength)
let i
// 遍历 commonLength,调用 patch 函数进行更新
for (i = 0; i < commonLength; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
patch(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
// 如果旧的一组子节点的长度大于新的一组子节点的长度,说明有旧的子节点需要卸载
if (oldLength > newLength) {
// 卸载旧子节点
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength
)
} else {
// 这里说明是有新子节点需要挂载
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
commonLength
)
}
}
如上面的代码所示,首先分别求出新旧两组子节点各自的长度,计算出两组子节点的公共长度 commonLength,即两者中较短的那一组子节点的长度。然后遍历 commonLength,然后调用 patch 函数对公共长度内的所有子节点进行更新。
也就是说,Vue 在进行新旧两组子节点的更新时,遍历的是两组子节点中长度较短的那一组,这样,才能够尽可能多地调用 patch 函数进行更新。
在对公共长度内的子节点更新完毕后,再对比新旧两组子节点的长度:
- 如果新的一组子节点的长度更长,则说明有新子节点需要挂载,此时调用
mountChildren函数对新子节点进行挂载。 - 否则说明有旧子节点需要卸载,此时调用
unmountChildren函数卸载旧节点。
有 key 属性的 Diff 比较
有 key 属性的 Diff 比较,会调用 patchKeyedChildren 函数来比较两组子节点。该函数的代码如下所示:
// packages/runtime-core/src/renderer.ts
// can be all-keyed or mixed
// 有 key 标识的两组子节点的 patch 过程,即 diff 过程
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
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
// 处理相同的前置节点
// 1. sync from start
// (a b) c
// (a b) d e
// 开启一个 while 循环查找所有相同的前置节点
while (i <= e1 && i <= e2) {
// 旧子节点
const n1 = c1[i]
// 新子节点
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 前置节点相同,调用 patch 打补丁
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// 遇到了 key 不同的节点,那就直接退出循环,相同前置节点(a b)的更新处理完成
break
}
i++
}
// 处理相同的后置节点
// 2. sync from end
// a (b c)
// d e (b c)
// 开启一个 while 循环,从后往前遍历两组子节点,查找所有相同的后置节点
// 直到遇到 key 值不同的节点为止
while (i <= e1 && i <= e2) {
// 旧的后置节点
const n1 = c1[e1]
// 新的后置节点
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
// 新旧后置节点相同,调用 patch 函数打补丁
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// 遇到了 key 不同的节点,退出循环,相同后置节(b c)点的更新处理完成
break
}
// 新旧后置节点索引递减,即从后往前遍历两组子节点
e1--
e2--
}
// 新增节点的情况
// 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
// i > e1 说明在预处理的过程中,所有旧子节点处理完毕了
// i <= e2 说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,这些遗留的节点将被视作新增节点。
** if (i > e1) {
if (i <= e2) {
// 锚点的索引
const nextPos = e2 + 1
// 锚点元素
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
// 采用 while 循环,调用 patch 函数逐个挂载新增节点
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: 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
// i > e2 说明新的一组子节点已经全部处理完毕了
// i <= e1 说明在旧的一组子节点中还有遗留的节点未被处理,这些节点是需要卸载的
else if (i > e2) {
// 开启一个 while 循环,并调用 unmount 函数逐个卸载这些遗留节点
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
// 在非理想情况下,经过预处理后,无论是新的一组子节点,还是旧的一组节点,都有部分节点未经处理
// 5. unknown sequence
// [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
else {
// 预处理完后,未处理节点的第一个未处理节点的索引位置
const s1 = i // prev starting index
const s2 = i // next starting index
// 5.1 build key:index map for newChildren
// 构建新的一组子节点中未处理节点的 key 和 索引位置的映射,是为了解决性能问题
// map 集合的键:节点的 key
// map 集合的值:节点的索引位置
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
// 新子节点
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (nextChild.key != null) {
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
// 将新节点的 key 和 索引位置添加到 map 集合中
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 当组件的子节点发生变化时,需要对新旧子节点数组进行 diff,并根据 diff 结果更新 DOM。在 diff 过程中,可能会有一些旧子节点数组中剩余未被处理的节点。这些节点需要在 diff 结束后进行处理,包括尝试与新子节点数组中的节点进行匹配并 patch,以及删除已经不存在的节点。
let j
// 代表更新过渡节点数量
let patched = 0
// 新的一组子节点中剩余未处理节点的数量
const toBePatched = e2 - s2 + 1
// 标识节点是否需要移动节点
let moved = false
// used to track whether any node has moved
// 代表遍历旧的一组子节点的过程中遇到的最大索引值
let maxNewIndexSoFar = 0
// works as Map<newIndex, oldIndex>
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
// 构建一个索引映射数组,存储新的一组子节点中在旧的一组子街道中的位置索引
const newIndexToOldIndexMap = new Array(toBePatched)
// 索引映射数组的初始值为 0,代表的是新数组中的节点在旧数组中不存在
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 遍历旧的一组子节点中剩余未处理的节点
for (i = s1; i <= e1; i++) {
// 旧数组中剩余未处理的节点
const prevChild = c1[i]
// 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
if (patched >= toBePatched) {
// 说明所有新子节点都已经被 patch,因此该节点只能是需要删除的节点。接着调用 unmount 函数将该节点从 DOM 中删除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
// 新的一组子节点中未被处理节点在新子节点中的位置索引
let newIndex
if (prevChild.key != null) {
// 从索引表中获取与旧节点具有相同key的新节点在新的一组子节点中的位置索引
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// key-less node, try to locate a key-less node of the same type
// 旧子节点没有 key ,那么尝试在新的一组子节点中查找具有相同类型的没有key的新子节点
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 如果在新的一组子节点中没有找到与旧的一组子节点中具有相同key 或相同类型的子节点,
// 说明该旧子节点在新的一组子节点中已经不存在了,需要将其卸载
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 填充 索引映射数组
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 通过比较 newIndex 和 maxNewIndexSoFar 的值来判断节点是否需要移动
if (newIndex >= maxNewIndexSoFar) {
// 如果在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动节点
maxNewIndexSoFar = newIndex
} else {
// 否则需要移动
moved = true
}
// 调用 patch 函数完成更新
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 每更新一个节点,都将 patched 变量 +1
patched++
}
}
// 5.3 移动并挂载
// 计算最长递增子序列?为了可以找到最少的移动操作,从而减少 DOM 操作的次数,提高更新性能
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap) // 获取给定数组的最长递增子序列
: EMPTY_ARR
// 索引 j 指向最长递增子序列的最后一个元素
j = increasingNewIndexSequence.length - 1
// i 指向新的一组子节点的最后一个元素
// for 循环 使得 i 自减
for (i = toBePatched - 1; i >= 0; i--) {
// 该节点在新 children 中的真实索引位置i
const nextIndex = s2 + i
// 新子节点
const nextChild = c2[nextIndex] as VNode
// 锚点
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// newIndexToOldIndexMap[i] === 0,说明索引为 i 的节点是全新的节点,应该将其挂载
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
// 这里是需要移动节点的情况
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
// i 指向的是新的一组子节点中元素的位置索引
// j 指向的是最长递增子序列中元素的位置索引
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 当指向新的一组子节点的元素的索引 i 不等于索引 j 指向的子序列中的元素时,
// 该节点对应的真实DOM元素需要移动
move(nextChild, container, anchor, MoveType.REORDER)
} else {
// 当 i === seq[j] 时,说明该位置的节点不需要移动,即让索引 j 递减
j--
}
}
}
}
}**
在对新旧两组有key属性的子节点进行 Diff 比较的过程中,有一个预处理的步骤。那么,什么是预处理呢?我们先来看看纯文本Diff算法中的预处理。
纯文本 Diff 算法的预处理
在纯文本 Diff 算法中,存在对两段文本进行预处理的过程。例如,在两段文本进行 Diff 之前,可以先对它们进行全等比较:
if (text1 === text2) return
如果两段文本全等,那么就无须进入核心 Diff 算法的步骤了。
除此之外,预处理过程还会处理文本相同的前缀和后缀。假设有如下两段文本:
TEXT1: I use vue for app delevopment
TEXT2: I use react for app delevopment
通过肉眼可以很容易发现,这两段文本的头部和尾部分别有一段相同的内容,如下图:
对于相同的内容,是不需要进行核心 Diff 操作的。因此,对于 TEXT1 和 TEXT2 来说,真正需要进行 Diff 操作的部分是:
TEXT1: vue
TEXT2: react
有 key 属性的 Diff 比较,在《Vue.js 设计与实现》一书中称作快速 Diff 算法。在本文中,我们也把有 key 属性的 Diff 比较称为快速 Diff 算法。
快速 Diff 算法中的预处理步骤,其实就是借鉴了纯文本 Diff 算法中预处理的步骤。快速 Diff 算法中的预处理,处理的是新旧两组子节点中相同的前置节点和后置节点。下面,我们来深入分析快速 Diff 算法中的预处理。
处理前置节点
我们观察下面的图:
通过观察上图可以发现,两组子节点具有相同的前置节点 p-1。对于前置节点,建立索引 i,其初始值为 0,用来指向两组子节点的开头,如上图所示。
然后开启一个 while 循环,让索引 i 递增,直到遇到不相同的节点为止,如下面 patchKeyedChildren 函数中的源码所示:
let i = 0
// 新的一组子节点的长度
const l2 = c2.length
// 旧的一组子节点最后一个节点的索引位置
let e1 = c1.length - 1 // prev ending index
// 新的一组子节点的最后一个节点的索引位置
let e2 = l2 - 1 // next ending index
// 处理相同的前置节点
// 1. sync from start
// (a b) c
// (a b) d e
// 开启一个 while 循环查找所有相同的前置节点
while (i <= e1 && i <= e2) {
// 旧子节点
const n1 = c1[i]
// 新子节点
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 前置节点相同,调用 patch 打补丁
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// 遇到了 key 不同的节点,退出循环,相同前置节点的更新处理完成
break
}
// 索引 i 递增
i++
}
在上面这段源码中,首先定义了一个索引 i ,初始值为 0,接着计算出新旧两组子节点的最后一个节点的索引位置,然后使用 while 循环,通过 isSameVNodeType 函数查找所有相同的前置节点,并调用 patch 函数对节点进行打补丁,直到遇到类型不同且 key 值不同的节点为止。这样就完成了前置节点的更新。
处理后置节点
由于新旧两组子节点的数量有可能不同,因此需要两个索引 e1 和 e2,别指向新旧两组子节点中的最后一个子节点,如下所示:
通过观察上图可以发现,两组子节点具有相同的后置节点 p-2 和 p-3。在处理后置节点时,也是开启一个 while 循环,并从后向前遍历这两组子节点,直到遇到 key 值不同的节点为止,如下面的代码所示:
// 处理相同的后置节点
// 2. sync from end
// a (b c)
// d e (b c)
// 开启一个 while 循环,从后往前遍历两组子节点,查找所有相同的后置节点
// 直到遇到 key 值不同的节点为止
while (i <= e1 && i <= e2) {
// 旧的后置节点
const n1 = c1[e1]
// 新的后置节点
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
// 新旧后置节点相同,调用 patch 函数打补丁
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
// 遇到了 key 不同的节点,退出循环,相同后置节点的更新处理完成
break
}
// 新旧后置节点索引递减,即从后往前遍历两组子节点
e1--
e2--
}
在上面这段源码中,使用了 while 循环查找所有相同的后置节点,并调用 patch 函数对节点进行打补丁,直到遇到类型不同且 key 值不同的节点为止。这样就完成了后置节点的更新。
新增节点
前置节点和后置节点处理完毕后,新旧两组子节点的状态如下图所示:
观察上图可知,当相同的前置节点和后置节点被处理完毕后,旧的一组子节点已经全部被处理了,而在新的一组子节点中,还遗留了一个未被处理的节点 p-4,而这个节点是一个新增节点。
观察 三个索引 i、e2 和 e1 之间的关系:
- 条件一:
e1 < i成立:说明在预处理过程中,所有旧子节点都处理完毕了。 - 条件二:
e2 >= i成立:说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,这些遗留的节点将被视作新增节点。
如果上述条件一和条件二同时成立,那么说明在新的一组子节点中,存在遗留节点未被处理,且这些节点都是新增节点,需要将它们挂载到正确的位置。挂载新增节点的代码如下所示:
// 新增节点的情况
// 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
// i > e1 说明在预处理的过程中,所有旧子节点处理完毕了
// i <= e2 说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,这些遗留的节点将被视作新增节点。
if (i > e1) {
if (i <= e2) {
// 锚点的索引
const nextPos = e2 + 1
// 锚点元素
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
// 采用 while 循环,调用 patch 函数逐个挂载新增节点
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
在上面这段源码中:
- 首先计算锚点的索引值 (即 nextPos) 为
e2 + 1。 - 如果
nextPos小于新的一组子节点的数量l2,则说明锚点元素在新的一组子节点中,所以直接使用(c2[nextPos] as VNode).el。 - 否则说明索引
c2对应的节点已经是尾部节点了,这时锚点元素为parentAnchor。 - 有了锚点元素之后,使用一个
while循环,遍历索引i和 索引e2之间的节点,并调用patch函数挂载它们。
删除节点
当相同的前置节点和后置节点全部处理完毕后,新的一组子节点已经全部处理完毕了,而旧的一组子节点中还有遗留的节点未被处理,这些节点都是需要卸载的。如下图所示:
如上图所示,在旧的一组子节点中,索引 i 和 索引 e1 之间的任何节点都应该被卸载,代码如下所示:
// 删除节点的情况
// 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
// i > e2 说明新的一组子节点已经全部处理完毕了
// i <= e1 说明在旧的一组子节点中还有遗留的节点未被处理,这些节点是需要卸载的
else if (i > e2) {
// 开启一个 while 循环,并调用 unmount 函数逐个卸载这些遗留节点
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
在上面的源码中,当满足条件 i > e2 时,则开启一个 while 循环,并调用 unmount 函数逐个卸载这些遗留节点。
至此,在理想化情况下,当处理完相同的前置节点或后置节点后,新旧两组子节点中总会有一组子节点全部被处理完毕。在这种情况下,只需要简单地挂载、卸载节点即可。
非理想情况下的未被处理节点
在非理想情况下,经过预处理后,无论是新的一组子节点,还是旧的一组节点,都有部分节点未经处理。如下图所示:
如上图所示,相同的前置节点只有 p-1,而相同的后置节点只有 p-5,经过预处理后,新旧两组子节点都有未经处理的节点。
在这种非理想情况下,当相同的前置节点和后置节点都被处理后,索引 i 、e2 和 e1 不满足下面两个条件中的任何一个:
- i > e1 && i < e2 (新增节点的情况)
- i > e2 && i < e1 (卸载旧节点的情况)
我们需要增加新的 else 分支来处理这种非理想情况,如下代码所示:
// packages/runtime-core/src/renderer.ts
// can be all-keyed or mixed
// 有 key 标识的两组子节点的 patch 过程,即 diff 过程
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
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
// 处理相同的前置节点
while (i <= e1 && i <= e2) {
// 省略处理相同的前置节点部分的代码
// 索引 i 递增
i++
}
// 处理相同的后置节点
while (i <= e1 && i <= e2) {
// 省略处理相同的后置节点部分的代码
// 新旧后置节点索引递减,即从后往前遍历两组子节点
e1--
e2--
}
// 新增节点的情况
if (i > e1) {
if (i <= e2) {
// 省略新增节点部分的代码
}
}
// 删除节点的情况
else if (i > e2) {
// 省略删除节点部分的代码
}
// 在非理想情况下,经过预处理后,无论是新的一组子节点,还是旧的一组节点,都有部分节点未经处理
else {
// 增加 else 分支来处理非理想情况
}
}
如上面的代码所示,我们对 patchKeyedChildren 函数的源码进行了精简,可以看到,在函数的最下面,增加了 else 分支来处理非理想情况下未被处理的节点。
要对非理想情况下未被处理的节点进行处理,就需要先找出那些需要移动的节点。接下来,我们来详细分析如何找出这些需要移动的节点。
找出需要移动的节点
1. 构建索引表
为新的一组子节点构建一张索引表,用来存储新的一组子节点的 key 和节点位置索引之间的映射,其目的是为了可以快速找到新的一组子节点中节点所在的位置,解决潜在的性能问题。如下图所示:
构建索引表的代码如下所示:
// 5.1 build key:index map for newChildren
// 构建新的一组子节点中未处理节点的 key 和 索引位置的映射,是为了解决性能问题
// map 集合的键是节点的 key
// map 集合的值是节点的索引位置
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
// 新子节点
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (nextChild.key != null) {
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
// 将新节点的 key 和 索引位置添加到 map 集合中
keyToNewIndexMap.set(nextChild.key, i)
}
}
如上面的源码所示,keyToNewIndexMap 集合用于存储新的一组子节点中未处理节点的 key 和 索引位置的映射。然后通过一个 for 循环,遍历新的一组子节点中未被处理的节点,将新节点的 key 和 索引位置存储到 keyToNewIndexMap 集合中。
2. 构造 newIndexToOldIndexMap 数组
为了找出需要移动的节点,还需要构造一个 newIndexToOldIndexMap 数组,它的长度等于新的一组子节点在经过预处理之后剩余未处理节点的数量,并且 newIndexToOldIndexMap 中每个元素的初始值都是0 ,如下图所示:
newIndexToOldIndexMap 数组的构造如下面的代码所示:
// 新的一组子节点中剩余未处理节点的数量
const toBePatched = e2 - s2 + 1
// 标识节点是否需要移动节点
let moved = false
// 代表遍历旧的一组子节点的过程中遇到的最大索引值
let maxNewIndexSoFar = 0
// 构建一个索引映射数组,存储新的一组子节点中在旧的一组子节点中的位置索引
const newIndexToOldIndexMap = new Array(toBePatched)
// 索引映射数组的初始值为 0,代表的是新数组中的节点在旧数组中不存在
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
在上面的源码中,通过两个索引 e2 和 s2 计算出新的一组子节点中剩余未处理节点的数量toBePatched,然后使用 new Array() 构造函数构造一个长度为 toBePatched 的 newIndexToOldIndexMap,接着遍历 toBePatched,将 newIndexToOldIndexMap 数组中每个元素的值初始化为 0。
newIndexToOldIndexMap 数组的作用
newIndexToOldIndexMap 数组中的每一个元素与新的一组子节点中剩余未处理节点是一一对应的。实际上,newIndexToOldIndexMap 数组用来存储新的一组子节点中的节点在旧的一组子节点中的位置索引,后面将会使用它计算出一个最长递增子序列,并用于辅助完成 DOM 移动的操作。如下图所示:
3. 填充 newIndexToOldIndexMap 数组
有了索引表 keyToNewIndexMap 和 newIndexToOldIndexMap 数组,接下来就需要根据索引表来填充 newIndexToOldIndexMap 数组,让数组中的元素存储的是新的一组子节点中的节点在旧的一组子节点中的位置索引。如下图所示:
使用索引表填充 newIndexToOldIndexMap 数组的源码如下:
// 遍历旧的一组子节点中剩余未处理的节点
for (i = s1; i <= e1; i++) {
// 旧数组中剩余未处理的节点
const prevChild = c1[i]
// 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
if (patched >= toBePatched) {
// all new children have been patched so this can only be a removal
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
// 新的一组子节点中未被处理节点在新子节点中的位置索引
let newIndex
if (prevChild.key != null) {
// 从索引表中获取与旧节点具有相同key的新节点在新的一组子节点中的位置索引
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 旧子节点没有 key ,那么尝试在新的一组子节点中查找具有相同类型的没有key的新子节点
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 如果在新的一组子节点中没有找到与旧的一组子节点中具有相同key 或相同类型的子节点,
// 说明该旧子节点在新的一组子节点中已经不存在了,需要将其卸载
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 填充 索引映射数组
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 通过比较 newIndex 和 maxNewIndexSoFar 的值来判断节点是否需要移动
if (newIndex >= maxNewIndexSoFar) {
// 如果在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动节点
maxNewIndexSoFar = newIndex
} else {
// 否则需要移动
moved = true
}
// 调用 patch 函数完成更新
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 每更新一个节点,都将 patched 变量 +1
patched++
}
}
在上面的代码中:
- 使用 for 循环来遍历旧的一组子节点,在遍历的过程中,如果已经更新的节点数量
patched超过了需要更新的节点数量toBePatched,那么调用unmount函数卸载剩余未被更新的旧子节点。 - 然后拿旧子节点的 key 值去索引表
keyToNewIndexMap中查找该节点在新的一组子节点中的位置,并将查找结果存储到newIndex中。如果旧子节点没有 key ,那么尝试在新的一组子节点中查找具有相同类型的的新子节点,将新子节点的索引存储到newIndex中。 - 如果
newIndex不存在说明该节点已经不存在于新的一组子节点中了, 这时我们需要调用 unmount 函数卸载它。 - 如果
newIndex存在,说明节点是可复用的,调用patch函数进行打补丁,并填充source数组。 - 这里需要注意的是,由于数组
source的索引是从 0 开始的,而未处理节点的索引未必从 0 开始,所以在填充数组时需要使用表达式newIndex - s2的值作为数组的索引值。
4. 判断节点是否需要移动
在源码中,定义了 moved 变量和 maxNewIndexSoFar 变量来判断节点是否需要移动,如下图中的代码所示:
在上面的代码中,其中 moved 的初始值为 false,代表是否需要移动节点。maxNewIndexSoFar 的初始值为 0,代表遍历旧的一组子节点的过程中遇到的最大索引值 newIndex 。如果在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动节点,反之则需要。所以在第二个for循环内,通过比较变量 newIndex 与变量 maxNewIndexSoFar 的值来判断是否需要移动节点。如果当前遍历的索引值 newIndex 比 maxNewIndexSoFar 大,则说明不需要移动节点,此时只需要更新 maxNewIndexSoFar 即可。否则说明需要移动节点,此时需要将 moved 变量置为 true 。
如何移动元素
在「构造 newIndexToOldIndexMap 数组」小节中,我们提到 newIndexToOldIndexMap 数组会被用来计算出一个最长递增子序列,用于辅助完成 DOM 移动的操作。接下来,我们就来看一下最长递增子序列的计算过程。
1. 计算最长递增子序列
我们先来简单了解下什么是最长递增子序列。简单来说,在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。递增子序列中的元素在原序列中不一定是连续的。一个序列可能有很多个递增子序列,其中最长的那一个就称为最长递增子序列。
以上图为例,根据上图中的 newIndexToOldIndexMap 数组,计算出它的最长递增子序列如下:
// 计算最长递增子序列
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap) // [0, 1]
: EMPTY_ARR
根据最长递增子序列的定义,上图中 newIndexToOldIndexMap 数组的最长递增子序列应该是 [2, 3],但计算得到的结果却是 [0, 1],这是为什么呢?这是因为 getSequence 函数返回的结果是最长递增子序列中的元素在 newIndexToOldIndexMap 数组中的位置索引,如下图所示:
其中元素 2 在 newIndexToOldIndexMap 数组中的索引为 0,元素 3 在 newIndexToOldIndexMap 数组中的索引为 1,所以最终结果为 [0, 1]。
下面我们来学习下最长递增子序列算法的实现
1.1 最优情况
Vue3 采用最长递增子序列,求解不需要移动的元素有哪些
function getSequence(arr) {
const len = arr.length;
// 保存最长递增子序列的索引
const result = [0];
let resultLastIndex;
for (let i = 0; i < len; i++) {
// 获取数组中的每一项,但是【0】没有意义我们需要忽略掉
const arrI = arr[i];
if (arrI !== 0) {
resultLastIndex = result[result.length - 1];
if (arr[resultLastIndex] < arrI) {
// 记录索引
result.push(i);
continue;
}
}
}
return result;
}
// 针对默认递增的序列进行优化
console.log(getSequence([2, 6, 7, 8, 9, 11]));
1.2 二分查找-查找最长递增个数
function getSequence1(arr) {
const len = arr.length;
const result = [0]; // 保存最长递增子序列的索引
let resultLastIndex;
let start;
let end;
let middle = 0;
for (let i = 0; i < len; i++) {
// 获取数组中的每一项,但是【0】没有意义我们需要忽略掉
const arrI = arr[i];
if (arrI !== 0) {
resultLastIndex = result[result.length - 1];
if (arr[resultLastIndex] < arrI) {
result.push(i); // 记录索引
continue;
}
start = 0;
end = result.length - 1; // 二分查找 前后索引
while (start < end) {
// 最终start = end
middle = ((start + end) / 2) | 0; // 向下取整
// 拿result中间值和最后一项比较
if (arr[result[middle]] < arrI) {
// 找比arrI大的值 或者等于arrI
start = middle + 1;
} else {
end = middle;
}
}
if (arrI < arr[result[start]]) {
// 当前这个小就替换掉
result[start] = i;
}
}
}
return result;
}
1.3 前驱节点追溯
假设有:
[2,3,1,5,6,8,7,9,4] 为最新序列 -> 按照上述结果得出的结论为:[ 2, 1, 8, 4, 6, 7 ]
function getSequence(arr) {
// 最终的结果是索引
const len = arr.length;
const result = [0]; // 索引,递增的序列,用二分查找性能高
const p = arr.slice(0); // 里面内容无所谓,和原本的数组相同,用来存放索引
let start;
let end;
let middle;
for (let i = 0; i < len; i++) {
// O(n)
const arrI = arr[i];
if (arrI !== 0) {
let resultLastIndex = result[result.length - 1];
// 取到索引对应的值
if (arr[resultLastIndex] < arrI) {
p[i] = resultLastIndex; // 标记当前,前一个对应的索引
result.push(i); // 当前的值比上一项大,就直接push,并且让这个数记录他的前一个
continue;
}
// 二分查找,找到比当前值大的那一个
start = 0;
end = result.length - 1;
while (start < end) {
// 重合就说明找到了 对应的值 // 时间复杂度:O(logn)
middle = ((start + end) / 2) | 0; // 找到中间位置的前一个
if (arr[result[middle]] < arrI) {
start = middle + 1;
} else {
end = middle;
} // 找到结果集中,比当前这一项大的数
}
// start / end 就是找到的位置
if (arrI < arr[result[start]]) {
// 如果【相同】,或者【比当前的还大】就不换了
if (start > 0) {
// 否则才需要替换
p[i] = result[start - 1]; // 要将他替换的前一个记住
}
result[start] = i;
}
}
}
let i = result.length; // 总长度
let last = result[i - 1]; // 找到了最后一项
while (i-- > 0) {
// 根据前驱节点一个个向前查找
result[i] = last; // 最后一项肯定是正确的
last = p[last];
}
return result;
}
console.log(getSequence([2, 3, 1, 5, 6, 8, 7, 9, 4]));
1.4 优化 Diff 算法
利用最长递增子序列,优化 Diff 算法,减少移动操作
// [5,3,4,0] => [1,2]
let increasingNewIndexSequence = getSequence(newIndexToOldMapIndex);
// 取出最后一个的索引
let j = increasingNewIndexSequence.length - 1;
for (let i = toBePatched - 1; i >= 0; i--) {
// 找到h的索引
let currentIndex = i + s2;
// 找到h对应的节点
let child = c2[currentIndex];
// 第一次插入 h 后,h是一个虚拟节点
let anchor = currentIndex + 1 < c2.length ? c2[currentIndex + 1].el : null;
if (newIndexToOldMapIndex[i] === 0) {
// 如果自己是0说明没有被patch过,就需要patch一次
patch(null, child, container, anchor);
} else {
if (i !== increasingNewIndexSequence[j]) {
hostInsert(child.el, container, anchor); // 操作当前的d,以d为下一个作为参照物插入
} else {
j--; // 跳过不需要移动的元素,为了减少移动操作,需要这个最长递增子序列算法
}
}
}
下面继续来看看节点的重新编号
2. 重新编号
重新编号,其实就是忽略掉已经预处理的前置节点和后置节点,对新旧两组字节点中未被处理的节点的索引值进行重新编号。如下图所示:
重新编号体现在源码中,就在for循环的条件表达式中,如下面的源码:
// i 指向新的一组子节点的最后一个元素
// for 循环 使得 i 自减
for (i = toBePatched - 1; i >= 0; i--) {
// 省略部分代码
}
在上面的代码中,toBePatched 变量代表的是新的一组子节点中剩余未处理节点的数量,将索引 i 重新指向新的一组子节点的最后一个元素的位置索引,即完成了对节点索引位置的重新编号。
最长递增子序列 seq 拥有一个非常重要的意义。子序列的值为 [0, 1],它的含义是:在新的一组子节点中,重新编号后索引值为 0 和 1 的这两个节点在更新前后顺序没有发生变化。换句话说,重新编号后,索引值为 0 和 1 的节点不需要移动。
如上图,在新的一组子节点中,节点 p-3 的索引为 0,节点 p-4 的索引为 1,所以节点 p-3 和节点 p-4 所对应的真实DOM节点不需要移动。换句话说,只有节点 p-2 和节点 p-7 可能需要移动。
3. 重置索引 i 和索引 j 的指向,辅助节点移动
为了完成节点的移动,源码中将索引 i 重新指向了新的一组子节点的最后一个节点,将索引 j 指向了最长递增子序列 seq 的最后一个元素,如下图所示:
然后开启一个 for 循环,让变量 j 和 i 按照上图中箭头的方向移动,即让 j 和 i 递减,如下图中的代码所示:
4. source[i] 值为 0 时,挂载节点
如果 newIndexToOldIndexMap[i] 的值为 0,说明索引为 i 的节点是全新的节点,我们需要调用 patch 函数将其挂载到容器中。
如下图所示,初始时索引 i 指向节点 p-7,由于节点 p-7 对应的 newIndexToOldIndexMap 数组中相同位置的元素值是 0,所以我们应该将节点 p-7 作为全新节点进行挂载。
需要注意的是,由于索引 i 是重新编号的,因此为了得到真实索引值,我们需要计算表达式 s2 + 1 的值。 源码实现如下:
5. i !== seq[j] 时,移动节点
当指向新的一组子节点的元素的索引 i 不等于索引 j 指向的子序列中的元素时,该节点对应的真实DOM元素需要移动。
如上图所示,此时索引 i 的值为 2 ,索引 j 的值为 1 ,因此 2 !== seq[1] 成立,节点 p-2 对应的真实节点需要移动。源码实现如下:
// 这里是需要移动节点的情况
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
// i 指向的是新的一组子节点中元素的位置索引
// j 指向的是最长递增子序列中元素的位置索引
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 当指向新的一组子节点的元素的索引 i 不等于索引 j 指向的子序列中的元素时,
// 该节点对应的真实DOM元素需要移动
move(nextChild, container, anchor, MoveType.REORDER)
}
6. i === seq[j] 时,无需移动节点
当 i === seq[j] 时,说明该位置的节点不需要移动,此时只需要让索引 j 按照图中箭头方向移动即可,即让变量 j 递减。如下图所示:
如上图所示,此时索引 i 的值为 1 ,索引 j 的值也为 1 ,因此 1 === seq[1] 成立,节点 p-4 对应的真实节点不需要移动,只需要让变量 j 递减即可。源码实现如下:
总结
本文通过图文结合的方式深入分析了Vue.js 3 中的 Diff 算法。
- 没有key属性的两组子节点在 Diff 比较时,会遍历新旧两组子节点中长度较短的那一组,这样,就能够尽可能多地调用 patch 函数进行更新。所以,如果没有定义 key 属性,在比较两个节点时,Vue 会认为这两个节点是同一个,哪怕它们实际上并不是,这就会导致频繁地更新元素,使得整个 patch 过程非常低效,影响性能。
- 对于有 key 属性的两组子节点的 Diff 比较,我们可以称其为快速 Diff 算法。它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列。最长子序列所指向的节点即为不需要移动的节点。