虚拟 DOM 与 Diff 算法:Vue 的高效更新策略
从"为什么列表更新后输入框内容错乱"这个真实 bug 出发,深入剖析 Vue 的模板编译、虚拟 DOM 结构、Diff 算法(双端比较)以及 key 的真实作用
你肯定遇到过这些怪事:
- 列表删除第一项,最后一项的输入框内容跑到第二项了
- 列表重新排序,勾选状态对不上了
- 加了 key 性能反而变差了
这些问题的根源都是:你没理解 Diff 算法的复用逻辑,以及 key 的真实作用。 这一篇我们从模板编译到虚拟 DOM,再到 Diff 算法,把 Vue 的更新链路讲透。
0. 先给你一句总纲:虚拟 DOM 不是为了"快",而是为了"可控的批量更新"
很多人认为虚拟 DOM 比原生 DOM 操作快,这是误解。
真相是:
- 直接操作 DOM(
innerHTML/createElement)在小规模下更快 - 但大规模更新时,虚拟 DOM 的优势在于:
- 批量 Diff:找出最小变更集
- 异步更新:合并多次修改,一次性提交
- 跨平台:同一套虚拟 DOM 可渲染到不同平台(Web/Native)
核心是:可预测的性能上限 + 开发体验。
1. 从模板到虚拟 DOM:Vue 的编译链路
1.1 模板编译的三个阶段
Vue 的模板不能直接运行,需要编译成渲染函数:
模板(Template) → AST(抽象语法树) → 渲染函数(render) → 虚拟 DOM(VNode)
举个例子:
<template> <div id="app" class="container"> <p>{{ message }}</p> <button @click="handleClick">Click</button> </div> </template>
阶段 1:解析(parse) - 生成 AST
{
type: 1, // 元素节点
tag: 'div',
attrsList: [
{ name: 'id', value: 'app' },
{ name: 'class', value: 'container' }
],
children: [
{
type: 1,
tag: 'p',
children: [
{ type: 2, expression: '_s(message)', text: '{{ message }}' }
]
},
{
type: 1,
tag: 'button',
events: { click: 'handleClick' },
children: [{ type: 3, text: 'Click' }]
}
]
}
阶段 2:优化(optimize) - 标记静态节点
Vue 会标记永远不会改变的节点,跳过 Diff:
{
type: 1,
tag: 'div',
static: false, // 动态节点(有绑定)
children: [
{
tag: 'button',
static: true, // 静态节点(纯文本)
children: [{ text: 'Click', static: true }]
}
]
}
阶段 3:生成(generate) - 生成渲染函数
function render() {
with (this) {
return _c('div', { attrs: { id: 'app', class: 'container' } }, [
_c('p', [_v(_s(message))]),
_c('button', { on: { click: handleClick } }, [_v('Click')]),
])
}
}
_c=createElement_v=createTextVNode_s=toString
1.2 渲染函数执行生成虚拟 DOM
{
tag: 'div',
data: {
attrs: { id: 'app', class: 'container' }
},
children: [
{
tag: 'p',
children: [
{ text: 'Hello Vue', isComment: false }
]
},
{
tag: 'button',
data: {
on: { click: handleClick }
},
children: [{ text: 'Click' }]
}
],
elm: undefined, // 真实 DOM 节点(初次为空)
key: undefined
}
关键字段:
tag:标签名或组件data:属性/事件/指令等children:子节点数组elm:对应的真实 DOM 节点key:节点唯一标识(Diff 关键)
2. Diff 算法:如何找出最小变更?
2.1 Diff 的核心假设
Vue(和 React)的 Diff 算法基于两个假设:
- 只做同层比较:不考虑跨层级移动
- 不同类型节点直接替换:不尝试复用
这把复杂度从 O(n³) 降到 O(n)。
2.2 单节点 Diff:sameVnode 判断
Vue 判断两个节点是否"相同",看这几个属性:
function sameVnode(a, b) {
return (
a.key === b.key && // key 必须相同
a.tag === b.tag && // 标签必须相同
a.isComment === b.isComment && // 注释节点标识
isDef(a.data) === isDef(b.data) && // data 存在性一致
sameInputType(a, b) // input 类型一致
)
}
如果相同:复用节点,更新属性和子节点 如果不同:删除旧节点,创建新节点
2.3 多节点 Diff:双端比较算法
这是 Vue 2 的核心 Diff 策略,也是面试重点。
场景:列表更新
// 旧节点
;[A, B, C, D]
// 新节点
;[B, A, D, C]
双端比较的四个指针
旧列表: [A, B, C, D] ↑ ↑ oldStart oldEnd 新列表: [B, A, D, C] ↑ ↑ newStart newEnd
比较步骤
第 1 轮:
oldStart(A)vsnewStart(B)→ 不同oldEnd(D)vsnewEnd(C)→ 不同oldStart(A)vsnewEnd(C)→ 不同oldEnd(D)vsnewStart(B)→ 不同- 用 key 在旧列表中查找 B → 找到索引 1
- 移动 B 到 oldStart 前面,标记旧 B 为 undefined
旧: [A, undefined, C, D] 新: [B, A, D, C] ↑
第 2 轮:
oldStart(A)vsnewStart(A)→ 相同!- 复用 A,指针向内移动
旧: [A✓, undefined, C, D] 新: [B✓, A✓, D, C]
第 3 轮:
- 跳过 undefined
oldStart(C)vsnewStart(D)→ 不同oldEnd(D)vsnewEnd(C)→ 不同oldStart(C)vsnewEnd(C)→ 相同!- 移动 C 到 oldEnd 后面
第 4 轮:
oldStart(D)vsnewStart(D)→ 相同!- 复用 D
最终结果:
[B, A, D, C]
操作统计:移动 2 次(B、C),复用 2 次(A、D)
2.4 Vue 3 的优化:最长递增子序列
Vue 3 改用快速 Diff 算法,核心是找最长递增子序列(LIS):
// 旧: [A, B, C, D, E]
// 新: [B, A, D, C, E]
// 1. 预处理:头尾相同节点
// 头: 无
// 尾: E 相同
// 2. 中间乱序部分: [A,B,C,D] → [B,A,D,C]
// 3. 建立新节点索引映射
// B→0, A→1, D→2, C→3
// 4. 在旧节点中找对应位置
// [1, 0, 3, 2] (B在旧列表位置1,A在0...)
// 5. 找最长递增子序列
// [0, 2] 对应 [A, C]
// 6. 只需移动非子序列节点 B 和 D
优势:减少移动次数,性能更好。
3. key 的真实作用:身份标识,而非性能开关
3.1 误区:key 只是为了性能
很多人以为"加了 key 就快",其实:key 的核心是确保节点身份稳定。
3.2 为什么 index 当 key 会出问题?
<li v-for="(item, index) in list" :key="index"> <input v-model="item.value" /> {{ item.name }} </li>
初始状态:
[ { name: 'A', value: '' }, // key=0 { name: 'B', value: '' }, // key=1 { name: 'C', value: '' } // key=2 ]
用户在第一个输入框输入 "Hello",然后删除第一项:
删除后:
[ { name: 'B', value: '' }, // key=0 (变了!) { name: 'C', value: '' } // key=1 (变了!) ]
Diff 结果:
- 旧 key=0 (A) vs 新 key=0 (B) → sameVnode 为 true (key 相同)
- Vue 认为是同一个节点,复用了 DOM
- 但数据变了,所以更新文本为 "B"
- 输入框的 value 没被 Vue 管理,所以"Hello"还在!
结果:B 的输入框显示 "Hello"(本该是 A 的)
3.3 正确做法:用唯一 ID
<li v-for="item in list" :key="item.id"> <input v-model="item.value" /> {{ item.name }} </li>
这样删除 A 时:
- 旧 key='a' 在新列表中不存在 → 销毁节点
- 新 key='b'/key='c' 在旧列表中存在 → 复用节点
输入框内容跟着正确的节点走。
3.4 什么时候可以用 index?
- 列表是静态的(不会增删改)
- 列表项没有状态(纯展示)
- 不会重新排序
例如:
<li v-for="(color, index) in ['red', 'green', 'blue']" :key="index"> {{ color }} </li>
4. 虚拟 DOM 的性能真相
4.1 虚拟 DOM 一定比原生快吗?
不一定!
| 场景 | 原生 DOM | 虚拟 DOM |
|---|---|---|
| 首次渲染 | 快(直接 innerHTML) | 慢(多一层 VNode 生成) |
| 小规模更新 | 快(直接改 DOM) | 慢(Diff 开销) |
| 大规模批量更新 | 慢(频繁重排重绘) | 快(Diff + 批量 patch) |
| 跨平台 | 不可能 | 可以(抽象层) |
4.2 虚拟 DOM 的真正价值
- 声明式编程:不需手动操作 DOM
- 批量更新:合并多次修改
- 跨平台能力:Web/Weex/小程序
- 可预测的性能:不会出现"超慢"的情况
Vue 3 的编译优化:
- 静态提升:静态节点只创建一次
- patchFlag:标记动态内容类型,跳过不必要的比较
- 事件缓存:事件处理函数不每次重新创建
5. 模板编译优化:Vue 3 的 Block Tree
5.1 Vue 2 的问题:全量 Diff
<div> <span>静态文本</span> <span>{{ dynamic }}</span> </div>
Vue 2 每次更新都要遍历所有子节点,即使第一个 <span> 永远不变。
5.2 Vue 3 的 Block Tree
编译时生成:
const _hoisted_1 = /*#__PURE__*/ _createElementVNode('span', null, '静态文本', -1)
export function render(_ctx) {
return (
_openBlock(),
_createElementBlock('div', null, [
_hoisted_1, // 静态节点提升
_createElementVNode('span', null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */),
])
)
}
patchFlag = 1 表示"只有文本内容是动态的",Diff 时只比较文本。
Block:收集所有动态子节点,Diff 时只遍历这些节点。
6. 面试高频问题汇总
Q1:虚拟 DOM 的优势是什么?
- 批量 Diff:最小化 DOM 操作
- 跨平台:抽象层可适配不同渲染目标
- 声明式:提升开发体验
- 可预测性能:避免极端情况
Q2:Vue 的 Diff 算法是怎样的?
- Vue 2:双端比较,四个指针,优先头尾匹配,再用 key 查找
- Vue 3:快速 Diff,最长递增子序列,减少移动次数
复杂度:O(n)
Q3:为什么不能用 index 做 key?
不是"不能用",而是有前提:
- 列表会增删改排序时,index 会变化,导致节点身份错乱
- 有状态的子组件(input/checkbox)会复用到错误的节点
适用场景:静态列表、纯展示
Q4:sameVnode 的判断条件是什么?
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
重点:key 相同 + tag 相同才复用
Q5:Vue 3 编译优化有哪些?
- 静态提升(hoisting):静态节点只创建一次
- patchFlag:标记动态类型,精准 Diff
- Block Tree:收集动态节点,跳过静态节点
- 事件缓存:避免每次重新创建函数
7. 总结:Diff 算法的心智模型
1. 新旧 VNode 树对比 ↓ 2. 同层比较(不跨层) ↓ 3. sameVnode 判断(key + tag) ↓ 4. 相同:复用 + patch 属性/子节点 不同:销毁 + 创建 ↓ 5. 多节点:双端比较/快速 Diff ↓ 6. 生成最小 DOM 操作指令 ↓ 7. 批量提交到真实 DOM
面试答题框架:
- 是什么:通过对比新旧虚拟 DOM,找出最小变更
- 怎么做:同层比较 + key 标识 + 双端/快速算法
- 为什么:减少 DOM 操作,批量更新,可预测性能
- 注意什么:key 的选择,静态优化,patchFlag
记住:Diff 的目标不是"完美最优",而是"工程可行的最优"。
留言讨论
Discussion
欢迎交流与反馈