Appearance
2.3 完整的 DOM Diff 流程是怎样的?(下)
在上一节中,我们了解了 Vue 3 DOM Diff 的整体流程和基础机制。本节将深入分析 Vue 3 Diff 算法的核心实现,重点探讨单节点 diff、多节点 diff、patch flags 优化以及与 Vue 2 的性能对比。
单节点 Diff:Key 的作用和优化
Key 的重要性
在 Vue 中,key 是一个特殊的属性,用于帮助 Vue 识别哪些列表项发生了变化、被添加或被移除。让我们看看 Vue 3 是如何利用 key 进行优化的:
typescript
// renderer.ts - isSameVNodeType 函数
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}这个函数是 diff 算法的基础,它通过比较 type 和 key 来判断两个 VNode 是否为同一个节点。
单节点 Diff 流程
当处理单个子节点时,Vue 3 的策略非常直接:
typescript
// 单节点 diff 的核心逻辑
if (isSameVNodeType(n1, n2)) {
// 相同节点,进行 patch
patch(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
} else {
// 不同节点,卸载旧节点,挂载新节点
unmount(n1, parentComponent, parentSuspense, true)
patch(null, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
}Key 的优化作用:
- 精确识别:通过 key 可以精确识别节点,避免错误的复用
- 减少 DOM 操作:相同 key 的节点会被复用,只更新差异部分
- 保持状态:组件的内部状态在 key 相同时得以保持
多节点 Diff:最长递增子序列算法
无 Key 列表的 Diff
对于没有 key 的列表,Vue 3 使用简单的策略:
typescript
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
// 1. 对比公共长度部分
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,
)
}
// 2. 处理剩余节点
if (oldLength > newLength) {
// 卸载多余的旧节点
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength,
)
} else {
// 挂载新增的节点
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
commonLength,
)
}
}这种策略简单但效率不高,因为它无法识别节点的移动,只能按位置进行比较。
有 Key 列表的 Diff
对于有 key 的列表,Vue 3 使用了更复杂但高效的算法:
typescript
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: 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. 从开始同步
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. 从结束同步
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. 公共序列 + 挂载
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] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
i++
}
}
}
// 4. 公共序列 + 卸载
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true, optimized)
i++
}
}
// 5. 未知序列
else {
// 这里是最复杂的情况,需要使用最长递增子序列算法
// ...
}
}最长递增子序列算法的应用
在处理"未知序列"时,Vue 3 使用了最长递增子序列(LIS)算法来最小化 DOM 移动操作:
typescript
// 未知序列处理的核心逻辑
else {
const s1 = i // 旧列表开始索引
const s2 = i // 新列表开始索引
// 5.1 为新列表构建 key:index 映射
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) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 遍历旧列表,尝试 patch 并移除不存在的节点
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, optimized)
continue
}
let newIndex
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// key 为 null,尝试找到相同类型的节点
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, optimized)
} 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 移动和挂载
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// 从后往前遍历,以便我们可以使用最后 patch 的节点作为锚点
for (i = toBePatched - 1; i >= 0; 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) {
// 挂载新节点
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
} else if (moved) {
// 移动节点
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
}最长递增子序列算法实现
typescript
function getSequence(arr: number[]): number[] {
const p = arr.slice()
const result = [0]
let i, j, u, v, c
const len = arr.length
for (i = 0; i < len; i++) {
const arrI = arr[i]
if (arrI !== 0) {
j = result[result.length - 1]
if (arr[j] < arrI) {
p[i] = j
result.push(i)
continue
}
u = 0
v = result.length - 1
while (u < v) {
c = (u + v) >> 1
if (arr[result[c]] < arrI) {
u = c + 1
} else {
v = c
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1]
}
result[u] = i
}
}
}
u = result.length
v = result[u - 1]
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}算法优势:
- 最小移动:通过 LIS 算法找到不需要移动的最长序列
- 时间复杂度:O(n log n),相比暴力解法的 O(n²) 有显著提升
- DOM 操作最少:只移动必要的节点,减少 DOM 操作成本
Patch Flags:编译时优化标记
Patch Flags 的定义
Vue 3 引入了 Patch Flags 机制,这是编译器生成的优化提示:
typescript
export enum PatchFlags {
/**
* 表示具有动态文本内容的元素
*/
TEXT = 1,
/**
* 表示具有动态 class 绑定的元素
*/
CLASS = 1 << 1,
/**
* 表示具有动态 style 的元素
*/
STYLE = 1 << 2,
/**
* 表示具有非 class/style 动态 props 的元素
*/
PROPS = 1 << 3,
/**
* 表示具有动态 key 的 props,需要完整 diff
*/
FULL_PROPS = 1 << 4,
/**
* 表示需要 props hydration 的元素
*/
NEED_HYDRATION = 1 << 5,
/**
* 表示子节点顺序不变的 fragment
*/
STABLE_FRAGMENT = 1 << 6,
/**
* 表示带有 key 或部分带 key 的子节点的 fragment
*/
KEYED_FRAGMENT = 1 << 7,
/**
* 表示没有 key 的子节点的 fragment
*/
UNKEYED_FRAGMENT = 1 << 8,
/**
* 表示只需要非 props patching 的元素
*/
NEED_PATCH = 1 << 9,
/**
* 表示具有动态插槽的组件
*/
DYNAMIC_SLOTS = 1 << 10,
/**
* 开发模式下的根 fragment 标记
*/
DEV_ROOT_FRAGMENT = 1 << 11,
/**
* 表示静态缓存的 vnode
*/
CACHED = -1,
/**
* 表示需要退出优化模式的特殊标记
*/
BAIL = -2,
}Patch Flags 在 Diff 中的应用
在 patchElement 函数中,Vue 3 根据 patch flags 进行精确更新:
typescript
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
const el = (n2.el = n1.el!)
let { patchFlag, dynamicChildren, dirs } = n2
// 合并旧节点的 patch flag
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
// 处理动态子节点
if (dynamicChildren) {
patchBlockChildren(
n1.dynamicChildren!,
dynamicChildren,
el,
parentComponent,
parentSuspense,
resolveChildrenNamespace(n2, namespace),
slotScopeIds,
)
} else if (!optimized) {
// 完整 diff
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
resolveChildrenNamespace(n2, namespace),
slotScopeIds,
false,
)
}
if (patchFlag > 0) {
// 根据 patch flag 进行精确更新
if (patchFlag & PatchFlags.FULL_PROPS) {
// 动态 key props,需要完整 diff
patchProps(el, oldProps, newProps, parentComponent, namespace)
} else {
// class 更新
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, namespace)
}
}
// style 更新
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, namespace)
}
// props 更新
if (patchFlag & PatchFlags.PROPS) {
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
if (next !== prev || key === 'value') {
hostPatchProp(el, key, prev, next, namespace, parentComponent)
}
}
}
}
// text 更新
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (!optimized && dynamicChildren == null) {
// 非优化模式,完整 diff
patchProps(el, oldProps, newProps, parentComponent, namespace)
}
}Patch Flags 的优势:
- 精确更新:只更新真正变化的属性
- 跳过静态内容:静态内容不会被重复处理
- 编译时优化:在编译阶段就确定了更新策略
- 减少运行时开销:避免不必要的属性比较
性能对比:Vue 2 vs Vue 3 Diff 算法改进
Vue 2 的 Diff 算法
Vue 2 使用的是经典的双端比较算法:
javascript
// Vue 2 的 updateChildren 函数(简化版)
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newEndIdx = newCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 使用 key 进行查找和移动
// ...
}
}
}Vue 3 的改进
| 方面 | Vue 2 | Vue 3 | 改进效果 |
|---|---|---|---|
| 算法策略 | 双端比较 | 最长递增子序列 | 减少移动操作 |
| 编译时优化 | 无 | Patch Flags | 精确更新 |
| 静态提升 | 有限 | 全面 | 减少创建开销 |
| Block Tree | 无 | 有 | 跳过静态节点 |
| 时间复杂度 | O(n) | O(n log n) | 在复杂场景下更优 |
| 内存使用 | 较高 | 较低 | 减少不必要的对象创建 |
性能测试对比
以一个包含 1000 个节点的列表为例:
javascript
// 测试场景:随机打乱列表顺序
const testData = Array.from({ length: 1000 }, (_, i) => ({ id: i, value: Math.random() }))
// Vue 2 性能特征
// - DOM 操作次数:约 500-800 次
// - 算法时间:O(n)
// - 内存占用:较高(需要维护多个指针)
// Vue 3 性能特征
// - DOM 操作次数:约 200-400 次
// - 算法时间:O(n log n)
// - 内存占用:较低(优化的数据结构)具体改进:
- 减少 DOM 操作:通过 LIS 算法,Vue 3 能够找到最优的移动策略
- 编译时优化:Patch Flags 让运行时只关注真正变化的部分
- Block Tree:静态节点被跳过,只处理动态节点
- 内存优化:减少了不必要的对象创建和内存分配
总结
Vue 3 的 DOM Diff 算法相比 Vue 2 有了显著的改进:
- 更智能的节点移动:通过最长递增子序列算法,最小化 DOM 移动操作
- 编译时优化:Patch Flags 机制让更新更加精确
- 更好的性能:在复杂场景下,性能提升明显
- 更少的内存占用:优化的数据结构和算法减少了内存开销
这些改进使得 Vue 3 在处理大型列表和复杂组件树时表现更加出色,为开发者提供了更好的性能体验。在下一节中,我们将探讨 Vue 3 的响应式系统是如何与 DOM Diff 协同工作的。
