Vue 3 Diff 算法深入
双端对比、最长递增子序列、key 的作用、性能优化
什么是 Diff 算法?
定义:对比新旧虚拟 DOM 树,找出最小变更并应用到真实 DOM 的算法。
核心目标:
- 最小化 DOM 操作:DOM 操作昂贵,尽量复用
- 保持正确性:确保视图与数据一致
- 高效性能:O(n) 时间复杂度
应用场景:
- 组件更新时对比新旧 VNode
- 列表渲染(v-for)的优化
- 条件渲染(v-if)的切换
一、Diff 算法基础
1.1 传统 Diff 的问题
完整树对比:时间复杂度 O(n³)
旧树:A - B - C
新树:A - C - D
完整对比需要:
1. 遍历旧树每个节点 O(n)
2. 在新树中查找 O(n)
3. 计算编辑距离 O(n)
总计:O(n³)1.2 Vue/React 的优化策略
三个假设:
- 同层对比:只比较同一层级的节点
- 不同类型节点:直接替换,不再深入对比
- key 标识:通过 key 识别节点是否可复用
时间复杂度:O(n)
typescript
function patch(
n1: VNode | null, // 旧节点
n2: VNode, // 新节点
container: RendererElement,
anchor?: RendererNode | null
) {
// 类型不同,直接替换
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1)
n1 = null
}
const { type, shapeFlag } = n2
switch (type) {
case Text:
processText(n1, n2, container, anchor)
break
case Comment:
processCommentNode(n1, n2, container, anchor)
break
case Fragment:
processFragment(n1, n2, container, anchor)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(n1, n2, container, anchor)
}
}
}
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}二、元素节点 Diff
2.1 processElement 流程
typescript
function processElement(
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null
) {
if (n1 == null) {
// 挂载新节点
mountElement(n2, container, anchor)
} else {
// 更新节点
patchElement(n1, n2)
}
}2.2 patchElement - 更新元素
typescript
function patchElement(
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
optimized: boolean
) {
const el = (n2.el = n1.el!)
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
// 1. 更新 props
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
)
// 2. 更新 children
const { patchFlag, dynamicChildren } = n2
if (patchFlag > 0) {
// 有优化标记
if (patchFlag & PatchFlags.FULL_PROPS) {
patchProps(el, n2, oldProps, newProps, ...)
}
if (dynamicChildren) {
// Block Tree 优化:只更新动态子节点
patchBlockChildren(
n1.dynamicChildren!,
dynamicChildren,
el,
...
)
return
}
}
// 完整 Diff 子节点
patchChildren(n1, n2, el, null, ...)
}2.3 patchChildren - 子节点 Diff
typescript
function patchChildren(
n1: VNode,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
optimized: boolean = false
) {
const c1 = n1 && n1.children
const prevShapeFlag = n1 ? n1.shapeFlag : 0
const c2 = n2.children
const { shapeFlag } = n2
// 新节点是文本
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧节点是数组,卸载所有子节点
unmountChildren(c1 as VNode[])
}
if (c2 !== c1) {
// 设置文本内容
hostSetElementText(container, c2 as string)
}
} else {
// 新节点是数组或空
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新旧都是数组 - 核心 Diff
patchKeyedChildren(
c1 as VNode[],
c2 as VNodeArrayChildren,
container,
anchor,
...
)
} else {
// 新节点为空,卸载旧节点
unmountChildren(c1 as VNode[])
}
} else {
// 旧节点是文本或空
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '')
}
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 挂载新节点
mountChildren(c2 as VNodeArrayChildren, container, anchor, ...)
}
}
}
}三、核心 Diff 算法 - patchKeyedChildren
3.1 算法概览
Vue 3 使用双端对比 + 最长递增子序列的混合算法:
五个步骤:
- 头部对比:从前往后比较相同节点
- 尾部对比:从后往前比较相同节点
- 新增节点:旧节点遍历完,新节点还有剩余
- 删除节点:新节点遍历完,旧节点还有剩余
- 乱序对比:使用最长递增子序列优化移动
3.2 完整实现
typescript
function patchKeyedChildren(
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
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
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = c2[i]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, ...)
} else {
break
}
i++
}
// 2. 尾部对比
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = c2[e2]
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, null, ...)
} else {
break
}
e1--
e2--
}
// 3. 新增节点
// (a b)
// (a b) c d
// i = 2, e1 = 1, e2 = 3
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor
while (i <= e2) {
patch(null, c2[i], container, anchor, ...)
i++
}
}
}
// 4. 删除节点
// (a b) c d
// (a b)
// i = 2, e1 = 3, e2 = 1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], ...)
i++
}
}
// 5. 乱序对比
// a b [c d e] f g
// a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
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]
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 遍历旧节点,patch 或 unmount
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, ...)
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) {
// 新节点中不存在,卸载
unmount(prevChild, ...)
} else {
// 记录位置映射
newIndexToOldIndexMap[newIndex - s2] = i + 1
// 判断是否需要移动
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// patch 节点
patch(prevChild, c2[newIndex], container, null, ...)
patched++
}
}
// 5.3 移动和挂载新节点
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]
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 新节点,挂载
patch(null, nextChild, container, anchor, ...)
} else if (moved) {
// 需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor)
} else {
j--
}
}
}
}
}3.3 算法图解
示例 1:头尾对比
旧: a b c d
新: a b c d e
步骤 1:头部对比
i=0: a === a ✓
i=1: b === b ✓
i=2: c === c ✓
i=3: d === d ✓
步骤 2:尾部对比(已完成)
步骤 3:新增节点
i=4 > e1=3,新增 e示例 2:删除节点
旧: a b c d e
新: a b c
步骤 1:头部对比
i=0: a === a ✓
i=1: b === b ✓
i=2: c === c ✓
步骤 2:尾部对比(已完成)
步骤 4:删除节点
i=3 <= e1=4,删除 d, e示例 3:乱序对比
旧: a b c d e f g
新: a b e d c h f g
步骤 1:头部对比
i=0: a === a ✓
i=1: b === b ✓
i=2: c !== e ✗
步骤 2:尾部对比
e1=6: g === g ✓
e1=5: f === f ✓
e1=4: e !== h ✗
步骤 5:乱序对比 [c d e] vs [e d c h]四、最长递增子序列(LIS)
4.1 为什么需要 LIS?
问题:在乱序对比中,如何最小化 DOM 移动?
示例:
旧: a b c d e
新: a e b c d
朴素做法:移动 e 到前面(1 次移动)
优化做法:移动 a 到后面(1 次移动)
但如果:
旧: a b c d e
新: b c d e a
朴素做法:移动 b c d e(4 次移动)
优化做法:移动 a 到后面(1 次移动)LIS 的作用:找出不需要移动的最长序列。
4.2 LIS 算法实现
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
}
// 二分查找,找到第一个大于 arrI 的位置
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
}4.3 LIS 示例
typescript
// 输入:新节点在旧节点中的位置
const arr = [2, 3, 1, 5, 6, 4, 8, 9, 7]
// 输出:最长递增子序列的索引
const lis = getSequence(arr)
// [2, 3, 5, 6, 8, 9] 的索引 -> [0, 1, 3, 4, 6, 7]
// 含义:这些位置的节点不需要移动
// 其他节点需要移动4.4 在 Diff 中的应用
typescript
// newIndexToOldIndexMap: [5, 3, 4, 0]
// 表示:
// 新节点[0] 在旧节点位置 5
// 新节点[1] 在旧节点位置 3
// 新节点[2] 在旧节点位置 4
// 新节点[3] 是新增的(0 表示不存在)
const lis = getSequence([5, 3, 4, 0])
// 返回 [1, 2](索引)
// 表示新节点[1]和[2]不需要移动
// 遍历时:
// i=3: 新增节点,挂载
// i=2: 在 lis 中,不移动
// i=1: 在 lis 中,不移动
// i=0: 不在 lis 中,移动五、key 的作用
5.1 为什么需要 key?
没有 key:
vue
<div v-for="item in list">{{ item }}</div>typescript
// 旧: [A, B, C]
// 新: [B, C, D]
// 没有 key,按索引对比:
// 索引 0: A -> B (更新)
// 索引 1: B -> C (更新)
// 索引 2: C -> D (更新)
// 3 次更新有 key:
vue
<div v-for="item in list" :key="item.id">{{ item }}</div>typescript
// 旧: [A, B, C]
// 新: [B, C, D]
// 有 key,按 key 对比:
// A 不在新列表,删除
// B 复用
// C 复用
// D 是新节点,挂载
// 1 次删除 + 1 次挂载5.2 key 的最佳实践
✅ 推荐:
vue
<!-- 使用唯一 ID -->
<div v-for="user in users" :key="user.id">
{{ user.name }}
</div>
<!-- 使用唯一字符串 -->
<div v-for="item in items" :key="`item-${item.id}`">
{{ item.text }}
</div>❌ 不推荐:
vue
<!-- 使用索引 -->
<div v-for="(item, index) in items" :key="index">
{{ item }}
</div>
<!-- 使用随机数 -->
<div v-for="item in items" :key="Math.random()">
{{ item }}
</div>5.3 key 的陷阱
问题 1:使用索引作为 key
vue
<template>
<div v-for="(item, index) in list" :key="index">
<input :value="item.name" />
</div>
</template>
<script setup>
const list = ref([
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
{ id: 3, name: 'C' }
])
// 删除第一项
list.value.shift()
</script>删除前:
key=0: A
key=1: B
key=2: C
删除后:
key=0: B (复用了 A 的 DOM,但数据是 B)
key=1: C (复用了 B 的 DOM,但数据是 C)
key=2: 删除
问题:input 的值不会更新!问题 2:key 不唯一
vue
<div v-for="item in list" :key="item.type">
{{ item.name }}
</div>
<!-- 如果多个 item 的 type 相同,会导致错误的复用 -->六、Vue 2 vs Vue 3 Diff 对比
6.1 核心区别
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 算法 | 双端对比 | 双端对比 + LIS |
| 优化 | 无编译时优化 | Patch Flag + Block Tree |
| 移动优化 | 简单移动 | 最长递增子序列 |
| 性能 | 基准 | 提升 1.3-2 倍 |
6.2 Vue 2 双端对比
typescript
// Vue 2 的简化实现
function updateChildren(oldCh, newCh) {
let oldStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newStartIdx = 0
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 (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 {
// 乱序对比
// ...
}
}
}6.3 性能对比
场景 1:顺序不变
旧: [A, B, C, D, E]
新: [A, B, C, D, E, F]
Vue 2: 5 次头头对比 + 1 次插入
Vue 3: 5 次头部对比 + 1 次插入
性能相当场景 2:逆序
旧: [A, B, C, D, E]
新: [E, D, C, B, A]
Vue 2: 多次移动
Vue 3: 使用 LIS,最小化移动
Vue 3 更快场景 3:大量静态节点
vue
<div>
<p>Static 1</p>
<p>Static 2</p>
<span>{{ dynamic }}</span>
<p>Static 3</p>
</div>
Vue 2: 对比所有节点
Vue 3: Block Tree,只对比 span
Vue 3 快 2 倍+面试高频题
1: Vue 的 Diff 算法是什么?
答案:
Vue 使用同层对比的 Diff 算法,时间复杂度 O(n)。
核心策略:
- 只比较同一层级的节点
- 不同类型节点直接替换
- 通过 key 识别节点是否可复用
Vue 3 优化:
- 双端对比 + 最长递增子序列
- Patch Flag 靶向更新
- Block Tree 跳过静态节点
2: 为什么 v-for 需要 key?
答案:
作用:
- 帮助 Vue 识别节点,提高复用率
- 避免"就地更新"导致的状态错乱
- 提升 Diff 性能
示例:
javascript
// 没有 key
旧: [A, B, C]
新: [B, C, D]
// 按索引对比,3 次更新
// 有 key
旧: [A, B, C]
新: [B, C, D]
// 按 key 对比,1 次删除 + 1 次挂载3: 为什么不能用索引作为 key?
答案:
问题:索引会随着数组变化而变化,导致错误的节点复用。
示例:
vue
<input v-for="(item, index) in list" :key="index" />
// 删除第一项
list.shift()
// 结果:
// 原本 key=1 的 input 现在变成 key=0
// DOM 被复用,但数据不对应
// 用户输入的值会错位正确做法:使用唯一且稳定的 ID。
4: Vue 3 的 Diff 算法相比 Vue 2 有什么优化?
答案:
Vue 3 优化:
- 最长递增子序列:最小化 DOM 移动
- Patch Flag:靶向更新,跳过静态属性
- Block Tree:只对比动态节点
- 静态提升:静态节点只创建一次
性能提升:
- 更新性能提升 1.3-2 倍
- 内存使用减少 54%
5: 什么是最长递增子序列?在 Diff 中如何应用?
答案:
定义:在一个序列中,找出最长的递增子序列。
在 Diff 中的应用:
- 找出不需要移动的节点
- 只移动其他节点
- 最小化 DOM 操作
示例:
javascript
newIndexToOldIndexMap: [5, 3, 4, 0]
// 新节点在旧节点中的位置
LIS: [3, 4]
// 位置 3, 4 是递增的,不需要移动
// 只需要移动位置 5 和挂载位置 06: Diff 算法的时间复杂度是多少?
答案:
Vue/React Diff:O(n)
传统树 Diff:O(n³)
优化策略:
- 只比较同层节点(降到 O(n²))
- 不同类型直接替换(降到 O(n))
- 使用 key 快速定位(保持 O(n))
7: 什么情况下 Diff 算法性能最差?
答案:
最差场景:
- 大量节点逆序
javascript
旧: [1, 2, 3, 4, 5, ..., 1000]
新: [1000, 999, ..., 2, 1]
// 需要大量移动- 没有 key 的大列表
vue
<div v-for="item in largeList">{{ item }}</div>
// 无法复用,全部重新创建- 频繁的增删改
javascript
// 每次操作都触发 Diff
setInterval(() => {
list.value.splice(random(), 1)
list.value.push(newItem)
}, 100)优化方案:
- 使用 key
- 虚拟滚动
- 防抖/节流
8: Block Tree 如何优化 Diff?
答案:
原理:
- 编译时收集所有动态节点
- 运行时只对比动态节点
- 跳过静态节点
示例:
vue
<div>
<p>Static 1</p>
<span>{{ msg }}</span>
<p>Static 2</p>
</div>
// Block Tree
Block {
dynamicChildren: [span] // 只收集动态节点
}
// Diff 时只对比 span,跳过两个 p性能提升:
- 静态节点越多,提升越明显
- 可提升 2-10 倍性能
9: 如何手动触发组件更新?
答案:
方法 1:修改响应式数据
javascript
const count = ref(0)
count.value++ // 自动触发更新方法 2:forceUpdate
javascript
import { getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
instance?.proxy?.$forceUpdate()方法 3:修改 key
vue
<Component :key="componentKey" />
<script setup>
const componentKey = ref(0)
function refresh() {
componentKey.value++ // 强制重新创建组件
}
</script>10: Diff 算法如何处理 Fragment?
答案:
Fragment:多个根节点的容器。
vue
<template>
<div>A</div>
<div>B</div>
</template>
// 编译为
Fragment([div(A), div(B)])Diff 处理:
typescript
if (type === Fragment) {
// 直接对比 children
patchChildren(n1, n2, container, ...)
}优化:
STABLE_FRAGMENT:子节点顺序不变KEYED_FRAGMENT:有 key 的 v-forUNKEYED_FRAGMENT:无 key 的 v-for
性能影响:
- Fragment 本身不创建 DOM
- 只对比子节点
- 性能优于包裹 div