在上文中,我们分析了 patchChildren 函数,了解到Vue会根据子节点是否带有key,分别调用 patchKeyedChildren 或 patchUnkeyedChildren 进行更新。本文我们将深入分析这两个函数的实现,理解Vue字节点Diff算法。
patchUnkeyedChildren实现分析
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
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
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,
namespace,
slotScopeIds,
optimized,
)
}
if (oldLength > newLength) {
// 移除多余的旧节点
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength,
)
} else {
// 挂载新增的节点
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
commonLength,
)
}
}
无key子节点的更新策略
-
按位置一一对应更新
- 取新旧子节点数组的最小长度
- 在公共长度范围内按位置进行patch
- 不考虑节点的复用,完全按照位置对应
-
处理长度差异
- 如果旧节点更多:移除多余的旧节点
- 如果新节点更多:挂载新增的节点
- 从commonLength位置开始处理
-
优化处理
- 通过optimized参数控制是否需要克隆已挂载的节点
- 对新节点进行标准化处理
patchKeyedChildren实现分析
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // 旧节点的结束索引
let e2 = l2 - 1 // 新节点的结束索引
// 1. 从头部开始同步
// (a b) c
// (a b) d e
// 这里的 a b 是相同的前缀节点,可以直接复用
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, parentComponent, parentSuspense,
namespace, slotScopeIds, optimized)
} else {
break
}
i++
}
// 2. 从尾部开始同步
// a (b c)
// d e (b c)
// 这里的 b c 是相同的后缀节点,可以直接复用
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, parentComponent, parentSuspense,
namespace, slotScopeIds, optimized)
} else {
break
}
e1--
e2--
}
// 3. 处理新增节点
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// 这种情况下需要新增节点 c
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
patch(null, c2[i], container, anchor, parentComponent,
parentSuspense, namespace, slotScopeIds, optimized)
i++
}
}
}
// 4. 处理需要删除的节点
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// 这种情况下需要删除节点 c
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
// 5. 处理未知序列
// 例如,有如下更新:
// 旧子节点: [p q] [a b c d e] [x y]
// 新子节点: [p q] [c e b a d f] [x y]
// 经过前面的首尾处理后:
// - 先同步了前缀 [p q]
// - 再同步了后缀 [x y]
// - 剩余中间部分 [a b c d e] -> [c e b a d f] 需要处理
else {
const s1 = i
const s2 = i
// 5.1 建立新节点的key到索引的映射
// 为剩余的 [c e b a d f] 建立映射:
// {
// c: 0,
// e: 1,
// b: 2,
// a: 3,
// d: 4,
// f: 5
// }
const keyToNewIndexMap = 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) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 遍历旧节点,尝试patch匹配的节点并移除不再存在的节点
// 遍历剩余的旧子节点 [a b c d e],尝试在新子节点中找到对应位置
// newIndexToOldIndexMap 记录新子节点在旧子节点中的位置
// 对于 [a b c d e] -> [c e b a d f] 的映射关系是:
// newIndexToOldIndexMap: [3, 5, 2, 1, 4, 0]
// 这个数组表示:
// 索引: 0 1 2 3 4 5
// 新节点: c e b a d f
// 旧位置: 3 5 2 1 4 0 (注:实际值都+1了,0表示新节点)
let j
let patched = 0
const toBePatched = e2 - s2 + 1 // 需要处理的新节点数量
let moved = false
let maxNewIndexSoFar = 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]
if (patched >= toBePatched) {
// 所有新节点都已patch,剩余的旧节点可以直接删除
unmount(prevChild, parentComponent, parentSuspense, true)
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] as VNode)
) {
newIndex = j
break
}
}
}
if (newIndex === undefined) {
// 在新子节点中找不到,需要删除
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
// 记录新节点在旧节点中的位置
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 用于判断是否需要移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// 更新节点
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
patched++
}
}
// 5.3 移动和挂载
// 最长递增子序列是 [0, 4],对应新节点序列中的c和d
// 这意味着这两个节点可以保持相对位置不变,其他节点需要移动
//
// Vue从后往前遍历处理节点:
// 1. f是新节点,直接创建并插入
// 2. d在最长递增子序列中,保持不动
// 3. a需要移动到d之前
// 4. b需要移动到a之前
// 5. e需要移动到b之前
// 6. c在最长递增子序列中,保持不动
const increasingNewIndexSequence = moved
? 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
// 确定锚点(下一个节点的DOM元素)
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 新节点,需要挂载
// 例如例子中的节点f
patch(null, nextChild, container, anchor, parentComponent,
parentSuspense, namespace, slotScopeIds, optimized)
} else if (moved) {
// 需要移动的节点
// 如果没有最长递增子序列,或当前节点不在最长递增子序列中
// 例如例子中的节点e、b、a需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
// 当前节点在最长递增子序列中,不需要移动
// 例如例子中的节点c和d
j--
}
}
}
}
}
带key子节点的更新策略
-
快速路径处理
- 从头部开始同步:处理相同的前缀节点
- 从尾部开始同步:处理相同的后缀节点
- 这两步可以快速处理序列两端相同的节点
-
处理剩余情况
- 新增节点:当旧节点处理完,但新节点还有剩余
- 删除节点:当新节点处理完,但旧节点还有剩余
- 这两种情况的处理都相对简单直接
-
未知序列处理
- 建立key到索引的映射:优化查找效率
- 处理可复用的节点:尽可能复用已有节点
- 最长递增子序列算法:优化节点移动
核心优化策略
-
双端比较算法
- 同时从头尾开始比较
- 快速处理节点位置相对固定的情况
- 减少不必要的比较和移动
-
key的作用
- 快速定位可复用的节点
- 避免不必要的DOM操作
- 提高diff效率
-
最长递增子序列
- 用于优化节点移动
- 确保最少的DOM移动操作
- 提高更新性能
总结
通过分析这两个函数,我们可以看到Vue在处理子节点更新时采用了不同的策略:
-
无key子节点
- 简单的位置对应更新
- 适用于静态或很少变化的列表
- 性能较差,不建议在动态列表中使用
-
带key子节点
- 复杂但高效的diff算法
- 优化的节点复用策略
- 最小化DOM操作
-
性能优化
- 多级别的优化策略
- 智能的节点复用
- 最优的DOM操作序列
这种分层的更新策略让Vue能够在不同场景下都能提供最优的更新性能。在实际开发中,我们应该:
- 总是为动态列表提供key
- 避免使用索引作为key
- 优先考虑稳定且唯一的值作为key
至此,我们已经分析完了Vue更新子节点的核心逻辑。在这个过程中,patch函数会被递归调用,直到完成整个组件树的更新。但这只是Vue更新系统的一部分,要完整理解Vue的更新机制,我们还需要探索:
-
响应式系统
- ReactiveEffect的实现原理
- 依赖收集和触发更新的机制
- 计算属性和监听器的实现
-
调度系统
- 更新队列的管理
- 异步更新的实现
- 性能优化策略
这些内容将在后续文章中详细分析。