引言
在 Vue3 中,Diff 算法(也称为 Patch 算法)是实现响应式更新的核心机制。它通过对比新旧虚拟 DOM,以最小代价更新真实 DOM,从而实现高效的视图渲染。本文将从通俗解释到源码剖析,带你深入理解 Vue3 Patch 算法的工作原理。
一、什么是 Patch 算法?
通俗理解
想象一下,你有两张图片:一张是当前屏幕显示的旧图片(旧 VNode),另一张是需要显示的新图片(新 VNode)。Patch 算法就像是一个精明的画家,他不会把整张画布重新涂一遍,而是只修改需要改变的部分。
┌─────────────────────────────────────────────────────────────┐
│ 旧 VNode 树 │
│ <div> │
│ <p>Hello</p> ──────┐ │
│ <span>World</span> │ 对比差异 │
│ </div> │ │
└─────────────────────────────┼───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 新 VNode 树 │
│ <div> │
│ <p>Hi</p> ← 只修改文字 │
│ <span>World</span> ← 无需修改 │
│ <button>Click</button> ← 新增节点 │
│ </div> │
└─────────────────────────────────────────────────────────────┘
核心目标
- 最小化 DOM 操作:只更新变化的部分,避免不必要的重绘
- 保持组件状态:复用已存在的 DOM 节点,保留其状态
- 高效对比:采用分层对比策略,时间复杂度 O(n)
二、Patch 算法的核心流程
整体流程图
新 VNode 旧 VNode
│ │
│ │
▼ ▼
┌───────────┐ ┌───────────┐
│ 对比类型 │ │ │
│ (sameVNode)│ │ │
└─────┬─────┘ └─────┬─────┘
│ │
│ 类型不同 │
│ (tag/key) │
▼ ▼
┌───────────┐ ┌───────────┐
│ 替换节点 │ │ 复用节点 │
│ (replace) │ │ (patch) │
└─────┬─────┘ └─────┬─────┘
│ │
│ │
└──────────┬───────────┘
│
▼
┌───────────┐
│ 更新属性 │
│ (patchProps)│
└─────┬─────┘
│
▼
┌───────────┐
│ 更新子节点 │
│ (patchChildren)│
└───────────┘
关键判断条件
typescript
// 判断两个节点是否可以复用(sameVNode)
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return (
n1.type === n2.type && // 标签类型相同
n1.key === n2.key && // key 值相同
// ... 其他条件
)
}
三、Patch 算法的核心场景
场景一:节点类型不同 → 直接替换
当新旧节点类型不同时,直接删除旧节点并创建新节点:
typescript
// packages/runtime-core/src/renderer.ts
const patch = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
...
) => {
if (n1 && !isSameVNodeType(n1, n2)) {
// 类型不同,直接替换
unmount(n1)
n1 = null
}
const { type } = n2
// ... 创建新节点
}
场景二:节点类型相同 → 深度对比
当节点类型相同时,需要对比并更新:
- 更新属性(patchProps)
- 更新子节点(patchChildren)
更新属性(patchProps)
typescript
// packages/runtime-dom/src/patchProp.ts
function patchProp(
el: Element,
key: string,
prevValue: any,
nextValue: any,
...
) {
if (key === 'class') {
// 更新 class
patchClass(el, nextValue)
} else if (key === 'style') {
// 更新 style
patchStyle(el, prevValue, nextValue)
} else if (shouldSetAsAttr(el, key, nextValue)) {
// 更新标准属性
setAttr(el, key, nextValue)
} else {
// 更新 DOM 属性
el[key] = nextValue
}
}
更新子节点(patchChildren)
这是 Patch 算法最核心的部分,Vue3 对此做了大量优化。
四、核心优化:Diff 算法详解
子节点对比策略
Vue3 的 Diff 算法采用了双端对比 策略,结合最长递增子序列算法,实现高效的节点复用。
双端对比(双指针法)
typescript
// packages/runtime-core/src/renderer.ts
const patchChildren = (
n1: VNode,
n2: VNode,
container: RendererElement,
...
) => {
const c1 = n1.children as VNode[]
const c2 = n2.children as VNode[]
// 双端指针
let i = 0
let e1 = c1.length - 1
let e2 = c2.length - 1
// 1. 从头部开始对比
while (i <= e1 && i <= e2) {
const n1Child = c1[i]
const n2Child = c2[i]
if (isSameVNodeType(n1Child, n2Child)) {
patch(n1Child, n2Child, container)
} else {
break
}
i++
}
// 2. 从尾部开始对比
while (i <= e1 && i <= e2) {
const n1Child = c1[e1]
const n2Child = c2[e2]
if (isSameVNodeType(n1Child, n2Child)) {
patch(n1Child, n2Child, container)
} else {
break
}
e1--
e2--
}
// 3. 新节点更多 → 添加新节点
if (i > e1) {
while (i <= e2) {
patch(null, c2[i], container)
i++
}
}
// 4. 旧节点更多 → 删除多余节点
else if (i > e2) {
while (i <= e1) {
unmount(c1[i])
i++
}
}
// 5. 中间乱序 → 最长递增子序列
else {
// ... 见下文
}
}
最长递增子序列(LIS)
当中间节点顺序混乱时,Vue3 使用最长递增子序列算法找到可以复用的节点:
typescript
// 示例:旧节点顺序 [a, b, c, d, e]
// 新节点顺序 [a, c, b, e, f]
// 构建索引映射表
const keyToNewIndexMap: Map<string | number, number> = new Map()
for (i = s2; i <= e2; i++) {
keyToNewIndexMap.set(c2[i].key!, i)
}
// 找到最长递增子序列
// 在这个例子中:[a, c, e] 是最长递增子序列
// 它们的索引在新数组中是递增的:0, 1, 3
// 这些节点可以保持不动,其他节点需要移动
最长递增子序列算法(O(n log n)):
typescript
function getSequence(arr: number[]): number[] {
const p = arr.slice()
const result = [0]
for (let i = 0; i < arr.length; i++) {
const arrI = arr[i]
if (arrI !== 0) {
const last = result[result.length - 1]
if (arr[last] < arrI) {
p[i] = last
result.push(i)
continue
}
let lo = 0, hi = result.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (arr[result[mid]] < arrI) {
lo = mid + 1
} else {
hi = mid
}
}
if (arrI < arr[result[lo]]) {
if (lo > 0) {
p[i] = result[lo - 1]
}
result[lo] = i
}
}
}
let i = result.length
let last = result[i - 1]
while (i-- > 0) {
result[i] = last
last = p[last]
}
return result
}
五、通俗解释:Diff 算法是如何工作的?
类比:整理书架
想象你是一个图书管理员,需要按照新的图书列表重新整理书架:
-
从左到右检查:如果书的位置和内容都对,就不用动;
-
从右到左检查:同样的道理,末尾的书如果已经正确,也不用动;
-
新书上架:如果新列表有多余的书,直接添加到合适位置;
-
旧书下架:如果旧列表有多余的书,把它们移除;
-
整理中间:对于中间乱序的书,找到可以保持不动的最长序列,只移动需要调整的书。
旧书架:[A, B, C, D, E]
新书架:[A, C, B, E, F]步骤:
- 从左检查:A 正确,保持不动
- 从右检查:E 正确,保持不动
- 添加新书:F 需要添加
- 删除旧书:D 需要删除
- 整理中间:C 和 B 需要交换位置
为什么要找最长递增子序列?
因为最长递增子序列中的元素在新数组中是按顺序排列的,它们不需要移动。只需要移动不在这个序列中的元素,这样可以最小化 DOM 操作次数。
六、Vue3 vs Vue2 Diff 算法对比
| 特性 | Vue2 | Vue3 |
|---|---|---|
| 对比策略 | 双端对比 | 双端对比 + 最长递增子序列 |
| 时间复杂度 | O(n) ~ O(n²) | O(n log n) |
| 优化重点 | 简单场景优化 | 复杂场景优化 |
| 静态节点提升 | 无 | 有(PatchFlags) |
| Fragment 支持 | 有限 | 完整支持 |
Vue3 的 PatchFlags 优化
Vue3 引入了 PatchFlags,在编译阶段标记节点的更新类型:
typescript
// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
TEXT = 1, // 文本内容更新
CLASS = 2, // class 更新
STYLE = 4, // style 更新
PROPS = 8, // 属性更新
FULL_PROPS = 16, // 所有属性更新
HYDRATE_EVENTS = 32,// 事件监听器更新
STABLE_FRAGMENT = 64,
KEYED_FRAGMENT = 128,
UNKEYED_FRAGMENT = 256,
NEED_PATCH = 512,
DYNAMIC_SLOTS = 1024,
HOISTED = -1, // 静态提升
BAIL = -2 // 退出对比
}
七、实战演示:Diff 算法效果
示例代码
vue
<template>
<div>
<div v-for="item in list" :key="item.id">
{{ item.name }}
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
{ id: 3, name: 'C' }
])
// 模拟数据变化
setTimeout(() => {
list.value = [
{ id: 1, name: 'A' },
{ id: 3, name: 'C' },
{ id: 2, name: 'B' },
{ id: 4, name: 'D' }
]
}, 2000)
</script>
Diff 过程分析
旧列表:[A(1), B(2), C(3)]
新列表:[A(1), C(3), B(2), D(4)]
对比过程:
1. 头部对比:A 相同,继续
2. 尾部对比:C 和 B 不同,停止
3. 新列表更长:需要添加 D
4. 中间乱序:C 和 B 需要交换
最长递增子序列:[A, C]
→ A 和 C 保持不动
→ B 需要移动到 C 后面
→ D 需要添加
八、总结
Vue3 的 Patch 算法通过以下策略实现高效的 DOM 更新:
- 双端对比:从两端向中间对比,快速定位不变的节点;
- 最长递增子序列:最小化节点移动次数;
- PatchFlags:编译期优化,精确标记更新类型;
- 静态提升:避免不必要的 diff。
理解 Patch 算法有助于我们写出更高效的 Vue 代码,特别是在处理大量列表数据时,合理设置 key 值可以显著提升性能。