diff 这块我读过几遍源码才顺:Vue 2 的双端比较和 Vue 3 的最长递增子序列不是一回事,key 用 index 会在列表重排时出问题。
Q1: 为什么要有虚拟 DOM?
1
jQuery 时代: 手动操作 DOM
在 jQuery 时代,开发者需要手动调用
document.getElementById、appendChild、removeChild 等 API 来操作 DOM。代码充斥着命令式的 DOM 操作逻辑,和业务逻辑耦合在一起,难以维护。2
致命痛点: 频繁 DOM 操作性能爆炸
每次 DOM 操作都可能触发浏览器的重排(reflow)和重绘(repaint)。一个复杂的列表更新如果逐条操作 DOM,会产生上百次重排,页面直接卡顿。
3
虚拟 DOM 的引入
虚拟 DOM 是一个轻量的 JS 对象,用来描述真实 DOM 的结构。框架先在内存中计算出新旧两棵虚拟 DOM 树的差异,再一次性把最小的变更批量应用到真实 DOM,大幅减少 DOM 操作次数。
4
声明式编程的基石
有了虚拟 DOM,开发者只需声明”数据是什么样的”(
{{ msg }}),框架自动帮你计算”怎么更新 DOM”。这种声明式编程模型彻底解放了开发者的心智负担。5
跨平台能力
虚拟 DOM 本质是 JS 对象,不依赖浏览器 API。Vue 可以把它渲染到 Web、Native(Weex/UniApp)、Canvas 甚至服务端(SSR)。这是虚拟 DOM 的附加价值,但不是它的核心设计动机。
虚拟 DOM 的本质:声明式编程的中间层
虚拟 DOM 不是一个性能优化方案——手写最优的 DOM 操作一定比虚拟 DOM 快。虚拟 DOM 的真正价值是在保证足够好的性能的同时,提供极大的开发体验提升。尤雨溪在 Vue 官方文档中明确指出:虚拟 DOM 的意义在于”提供一个声明式的、可组合的抽象层,让开发者专注于状态管理而非 DOM 操作”。 Vue 3 在保留虚拟 DOM 的同时,通过编译时优化(PatchFlag、Block Tree、静态提升)大幅减少了运行时开销,让模板编译后的代码在大多数场景下接近甚至超过手写 render 函数的性能。虚拟 DOM 是声明式编程的桥梁——开发者声明状态,框架自动高效更新 DOM。
没有虚拟 DOM
你需要手动计算每次数据变化对应的 DOM 操作。想象你是一个仓库管理员,每次货架上少了 3 件商品,你得自己拿纸笔对比账本和货架,逐个核对每个商品的数量差异,再一个个上架补货。
引入虚拟 DOM
框架帮你自动对比”账本”和”货架”的差异,一次性告诉你”只需要补 A 商品 2 件、B 商品 1 件”。你只管更新数据(账本),diff 算法帮你算出最小变更清单,批量执行。
Q2: Vue 2 vs Vue 3 的 diff 算法有什么区别?
双端比较 vs LIS 优化
Vue 2 使用双端比较算法(新旧头尾四个指针),Vue 3 改为前后缀同步 + 中间乱序部分的最长递增子序列(LIS):diff 的三条铁律
Vue 的 diff 不是逐节点暴力比较,而是有三条设计约束:- 同层比较:只比较同一层级的节点,不跨层级移动。这把时间复杂度从 O(n^3) 降到 O(n)。
- 类型判断:节点类型(type)不同,直接销毁旧节点、重建新节点,不比较子树。
- key 标识:同类型节点用 key 区分身份。没有 key 时 Vue 退化为按索引逐一比较,可能导致状态错乱。
为什么前后缀同步能覆盖大多数场景
Vue 3 的前后缀同步(从左到右 + 从右到左)用 O(min(e1, e2)) 的时间覆盖了 80% 的真实场景——因为大多数列表更新都只是尾部增删或中间内容修改,节点位置不变。只有当这两步都无法处理时,才进入第三步的 LIS 计算。React 的单端遍历即使只是尾部加一个元素,也要遍历整个列表建立 key 映射。LIS 算法的时间复杂度分析
Vue 3 的 LIS 算法采用动态规划 + 贪心 + 二分查找,时间复杂度 O(n log n)。具体实现中,用一个result 数组维护当前的最长递增子序列,每来一个新元素,用二分查找确定它在 result 中的位置——如果比所有元素都大就追加,否则替换第一个比它大的元素。最终 result 的长度就是 LIS 的长度。
PatchFlag、Block Tree、静态提升等编译器优化的详细原理,详见 Vue 编译器。
Q3: 为什么不能用 index 做 key?
经典场景:列表中间删除一个元素
用唯一 ID 做 key
底层源码:Vue 3 的 diff 是如何实现的?
Vue 3 的 diff 核心函数在哪里?
Vue 3 的 diff 实现在 patchKeyedChildren 函数中(详见 renderer.ts#L1794):
LIS 算法是怎么实现的?
getSequence 与 patchKeyedChildren 同在 renderer.ts 中,详见 renderer.ts:
十、及格回答 vs 满分回答
问题:“Vue 3 的 diff 算法是怎么工作的?”
及格回答(60-70分):“Vue 3 用最长递增子序列优化 diff。用 key 建立映射,找出不需要移动的节点,只移动不在序列中的节点。”问题出在:知道结论但不理解设计。 满分回答(90-100分):
Vue 3 的 diff 算法在patchKeyedChildren函数中实现(位于packages/runtime-core/src/renderer.ts),分三步走。第一步从左到右逐一比较,处理最常见的”只有内容更新、位置没变”的场景;第二步从右到左,处理尾部新增/删除的场景。这两步用 O(min) 时间覆盖了 80% 的真实场景——因为大多数列表更新都只是尾部增删或中间内容修改,节点位置不变。只有当这两步都无法处理时,才进入第三步的复杂逻辑。[1]
第三步是中间乱序部分的核心优化:用keyToNewIndexMap建立 key 到新索引的映射(O(1) 查找),遍历旧节点查找可复用的,构建newIndexToOldIndexMap数组。然后用动态规划 + 贪心计算最长递增子序列(LIS),LIS 中的节点在旧列表中也是递增的,说明相对位置没变,不需要移动 DOM。这个算法在renderer.ts的getSequence函数中实现,时间复杂度 O(n log n),比 Vue 2 双端比较在最坏情况下的 O(n²) 更优,且随 n 增大差距会持续扩大。[2]
除了 diff 本身,Vue 3 还有三个编译器层面的优化:PatchFlag(位掩码标记动态绑定类型,运行时只更新标记部分,而不是比较所有属性)、Block Tree(扁平化动态后代数组,跳过静态子树,只遍历有动态绑定的节点)、静态提升(不依赖响应式的 VNode 只创建一次,后续更新直接复用)。这三个优化让 Vue 3 在绝大多数场景下比手写 render 函数还快——因为手写 render 函数无法享受编译器的静态分析。[3]
常见追问
题目来源于真实面经(字节/腾讯/阿里前端面试、牛客网、haizlin/fe-interview),按难度递进。
为什么不能用 index 做 key?
为什么不能用 index 做 key?
答题要点:
核心是 index 不能代表节点身份。当列表中间增删元素时,后续元素的 index 全部变化,框架认为所有节点都变了。高分回答:
“用 index 做 key 时,删除列表中间的元素会导致后续所有元素的 key 依次前移。例如删掉 index=0 的元素,原来 index=1 的元素变成 index=0,Vue 认为 key=0 的节点还在但 props 变了,于是强制更新而不是复用。如果节点内部有状态(如输入框内容、组件内部 data),状态会错乱——用户在原来 index=1 的输入框里打的字,会跑到 index=0 的位置。解决方案是用数据的唯一标识(id、uuid)做 key。这样删除元素时,只有被删节点的 key 消失,其他节点的 key 不变,Vue 能精准识别哪些节点需要删除、哪些需要复用。”
Vue 3 的静态提升具体是怎么做的?
Vue 3 的静态提升具体是怎么做的?
答题要点:
区分”节点静态提升”和”事件缓存”两个层面。高分回答:
“静态提升是编译器把不依赖响应式数据的 VNode 创建代码提升到渲染函数外部,只执行一次。例如
<div><h1>Hello</h1><span>{{ msg }}</span></div> 中,h1 节点完全静态,编译器会把它提升为模块级别的常量 _hoisted_1。后续每次渲染,这个 h1 的 VNode 直接复用引用,连对象创建都省了。还有事件缓存:@click="handler" 在 Vue 3 中会被编译为 _cache[0] || (_cache[0] = (...args) => handler(...args)),确保内联函数不每次创建新的引用,避免子组件的 props 变化触发不必要的 diff。”Block Tree 的动态节点追踪是怎么实现的?
Block Tree 的动态节点追踪是怎么实现的?
答题要点:
Block 的核心是
dynamicChildren 数组——运行时把所有带 PatchFlag 的动态节点”收集”到一个扁平数组中。高分回答:
“Block 是通过 openBlock() + createBlock() 实现的。在渲染函数执行时,openBlock() 初始化一个全局的 currentBlock 数组。当创建每个 VNode 时,如果它有 PatchFlag(说明是动态的),就会被 push 进 currentBlock。最后 createBlock() 把这个数组赋值给 Block VNode 的 dynamicChildren。重渲染 diff 时,如果一个 VNode 有 dynamicChildren,就只遍历这个数组而不遍历 children。这意味着一棵有 1000 个静态节点 + 3 个动态节点的子树,diff 只比较 3 个节点。代价是 v-if/v-for 会创建新的子 Block,打断父 Block 的 dynamicChildren 收集,所以深层嵌套的条件渲染会削弱这个优化。”Vue 3 的 diff 和 React 的 diff 有什么本质区别?
Vue 3 的 diff 和 React 的 diff 有什么本质区别?
答题要点:
从算法、编译优化、调度策略三个维度对比。高分回答:
“算法层面,React 的 diff 用单端遍历 + key 映射(reconciliation),Vue 3 用前后缀同步 + LIS 优化。两者时间复杂度都是 O(n),但 Vue 的前后缀同步在’头部/尾部增删’场景下更高效——React 即使只是尾部加一个元素,也要遍历整个列表。编译优化是最大差异:Vue 3 的编译器能生成 PatchFlag、Block Tree、静态提升等优化代码,让运行时 diff 只比较动态部分。React 没有这个层次的编译优化——React Compiler(原 React Forget)只是自动添加 memo/useMemo,减少不必要的 re-render,但 diff 本身还是要全量比较 props。调度策略上,Vue 3 用 queueJob + 微任务实现同步批量更新(同一事件循环内的多次修改只触发一次渲染)。React 18 用 Concurrent Mode 实现了时间切片和优先级调度(高优先级更新可以打断低优先级更新),在复杂场景下交互响应更流畅。”
没有 key 时 Vue 是怎么 diff 的?
没有 key 时 Vue 是怎么 diff 的?
答题要点: 没有 key 时 Vue 按索引逐一比较,可能导致状态错乱。高分回答:
“没有 key 时,Vue 2 和 Vue 3 都会退化为按索引逐一比较。具体来说,如果旧列表有 3 个节点、新列表有 2 个节点,Vue 会:
- 按索引 0、1 逐一比较旧节点和新节点,类型相同就 patch(更新 props)
- 多出来的旧节点(索引 2)直接销毁
PatchFlag 的位运算具体是怎么做的?
PatchFlag 的位运算具体是怎么做的?
答题要点: 用按位或 (|) 组合标记,用按位与 (&) 检查标记。高分回答:
“PatchFlag 是一个枚举值,用二进制位掩码表示。每个动态绑定类型占一个 bit:编译器用按位或组合:
PatchFlag.CLASS | PatchFlag.STYLE = 2 | 4 = 6(二进制 0000 0110)。
运行时用按位与检查:6 & PatchFlag.CLASS = 6 & 2 = 2(truthy,需要更新 class)。
6 & PatchFlag.TEXT = 6 & 1 = 0(falsy,跳过文本更新)。位运算的性能极高——一条 CPU 指令就能完成,比遍历对象属性快几个数量级。”v-if 和 v-for 对 Block Tree 有什么影响?
v-if 和 v-for 对 Block Tree 有什么影响?
答题要点: v-if/v-for 会创建子 Block,打断父 Block 的 dynamicChildren 收集。高分回答:
“Block Tree 的核心是
dynamicChildren——运行时把所有带 PatchFlag 的动态节点收集到一个扁平数组中。但 v-if 和 v-for 会改变 DOM 结构(节点数量和层级不稳定),所以它们会创建自己的子 Block,打断父 Block 的 dynamicChildren 收集。具体来说:当编译器遇到 v-if 时,会在 true/false 两个分支上各创建一个 Block。当遇到 v-for 时,会为列表的每一项创建一个 Block。这些子 Block 各自维护自己的 dynamicChildren,父 Block 只收集到子 Block 本身(作为一个整体),不再深入子 Block 内部收集动态节点。这意味着深层嵌套的 v-if/v-for 会削弱 Block Tree 的优化效果——因为每个嵌套层级都会打断扁平化。”大型列表的 diff 性能优化有哪些实战方案?
大型列表的 diff 性能优化有哪些实战方案?
答题要点: 虚拟滚动 + key 优化 + 组件粒度控制。高分回答:
“大型列表(1000+ 条)的 diff 性能优化有三个层面:1)虚拟滚动:只渲染可视区域内的节点(通常 20-30 个),用 CSS transform 模拟滚动。库如 vue-virtual-scroller、@tanstack/vue-virtual。这是最根本的优化——把 1000 个节点的 diff 降到 30 个。2)key 优化:用数据的唯一 ID 做 key,避免 index 做 key 导致的状态错乱和不必要的 DOM 更新。3)组件粒度控制:把列表项封装为独立组件,配合
shallowRef 或 v-memo(Vue 3.2+)减少不必要的 re-render。v-memo 可以指定依赖项,只有依赖项变化时才重新渲染该节点。4)避免在 v-for 中使用 v-if:Vue 3 中 v-if 的优先级高于 v-for,所以同一元素上的 v-if 访问不到 v-for 的作用域变量(v-for 优先是 Vue 2 的行为)。应避免在同一元素上同时使用二者,改用 computed 预先过滤列表,或用 <template v-for> 包裹。“Q4: 虚拟 DOM 真的比操作原生 DOM 快吗?
Vue vs React 视角下的虚拟 DOM 性能对比,详见 Vue vs React 架构对比。
答案:不是
这是一个经典的面试陷阱题。虚拟 DOM 不比直接操作原生 DOM 快。如果你手写了最优的 DOM 操作代码,它一定比虚拟 DOM 快——因为虚拟 DOM 多了一层 JS 对象的创建和 diff 计算。那虚拟 DOM 的价值是什么?
虚拟 DOM 的价值不是”快”,而是在保证足够好的性能的同时,提供了极大的开发体验提升。Q5: 虚拟 DOM 快在哪里?慢在哪里?
快在哪里
1. 批量更新慢在哪里
1. JS 对象的创建开销性能对比表
Q6: 为什么不直接干掉虚拟 DOM?
虚拟 DOM 的不可替代性
1. 声明式编程的基石有没有比虚拟 DOM 更好的方案?
Svelte 的编译时方案 Svelte 不用虚拟 DOM,而是在编译时把组件转换为直接操作 DOM 的代码。结论
延伸阅读与引用参考
- Vue 3 源码:renderer.ts - patchKeyedChildren:diff 算法的完整实现,从前缀/后缀同步到 LIS 优化的每一步。[1]
- Vue 3 源码:renderer.ts - getSequence:动态规划 + 贪心 + 二分查找的经典实现。[2]
- Vue 3 官方文档:渲染机制:尤雨溪对 PatchFlag、Block Tree、静态提升等编译器优化的最权威解释。[3]
- 霍春阳《Vue.js 设计与实现》:从虚拟 DOM 到 diff 算法的完整实现剖析,包含 LIS 算法的详细推导。
- 刘博文《深入浅出 Vue.js》:从 Vue 2 时代开始的源码级解析,对 patch 和 diff 的演进有完整覆盖。
