Skip to main content
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),反复试错零成本。等方案定好了(diff 计算出差异),施工队才去改真实房子(更新真实 DOM),而且只改需要动的地方。没有虚拟 DOM 的话,你每次想改个窗户都得直接拆墙,代价大、风险高。
虚拟 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 算法帮你算出最小变更清单,批量执行。
回答时先说”为什么”(声明式编程 + 批量更新),再说”怎么做”(虚拟 DOM + diff)。想听的是你对声明式编程为什么需要虚拟 DOM 这个中间层的理解,而不是背”虚拟 DOM 比真实 DOM 快”——这句话本身是错的。

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 的长度。
Vue 2 的双端比较就像你搬家时,从书架两头开始配对旧书和新书架上的位置——头对头、尾对尾、头对尾、尾对头,四次配对都不对才去翻箱子找。Vue 3 的 LIS 策略更聪明:你先把所有书编号,找出”本来就不用挪位置的那几本”(最长递增子序列),只搬运剩下的。就像你发现有 10 本书本来就在正确的位置上,只需要动 3 本,搬家效率立刻翻倍。

PatchFlag、Block Tree、静态提升等编译器优化的详细原理,详见 Vue 编译器。

Q3: 为什么不能用 index 做 key?

经典场景:列表中间删除一个元素

用唯一 ID 做 key

用 index 做 key 就像老师按座位号点名——如果第一个同学转学了(删除列表头部元素),后面所有同学的座位号都往前挪了一位,老师会把二号座位的同学当成原来的一号同学(状态错乱)。用唯一 ID 做 key 就像用学号点名——不管座位怎么换,学号永远不变,老师能准确识别每个学生,不会张冠李戴。
关键区别:
永远用数据的唯一标识(id、uuid)做 key。如果没有唯一标识,宁可生成一个,也绝对不要用 index。v-for="(item, index) in list" 中的 index 只是遍历的下标,不代表节点的身份。

底层源码:Vue 3 的 diff 是如何实现的?

Vue 3 的 diff 实现在 patchKeyedChildren 函数中(详见 renderer.ts#L1794):
这三步设计的精妙之处:前两步用 O(min(e1, e2)) 的时间处理了最常见的”头部/尾部新增删除”场景,只有真正乱序的部分才进入第三步的 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 不能代表节点身份。当列表中间增删元素时,后续元素的 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 能精准识别哪些节点需要删除、哪些需要复用。”
答题要点: 区分”节点静态提升”和”事件缓存”两个层面。高分回答: “静态提升是编译器把不依赖响应式数据的 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 的核心是 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 收集,所以深层嵌套的条件渲染会削弱这个优化。”
答题要点: 从算法、编译优化、调度策略三个维度对比。高分回答: “算法层面,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 按索引逐一比较,可能导致状态错乱。高分回答: “没有 key 时,Vue 2 和 Vue 3 都会退化为按索引逐一比较。具体来说,如果旧列表有 3 个节点、新列表有 2 个节点,Vue 会:
  1. 按索引 0、1 逐一比较旧节点和新节点,类型相同就 patch(更新 props)
  2. 多出来的旧节点(索引 2)直接销毁
这种模式在列表末尾增删时没有问题,但在列表中间增删时会出错——因为索引变化了,Vue 认为同一个索引的节点还是同一个,只是 props 变了,于是强制更新而不是移动节点。这就是为什么 Vue 会给你一个 warning:‘You are using v-for without key’。”
答题要点: 用按位或 (|) 组合标记,用按位与 (&) 检查标记。高分回答: “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,打断父 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 的优化效果——因为每个嵌套层级都会打断扁平化。”
答题要点: 虚拟滚动 + 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 快吗?

虚拟 DOM 就像自动驾驶——它不是最快的驾驶方式(顶级赛车手手动开肯定更快),但它足够安全、足够高效,而且你不需要成为赛车手就能上路。手写最优 DOM 操作就像职业赛车手手动驾驶——性能天花板更高,但对驾驶技术要求极高,普通开发者(大多数场景)用自动驾驶(虚拟 DOM)反而更安全、更高效。
Vue vs React 视角下的虚拟 DOM 性能对比,详见 Vue vs React 架构对比。

答案:不是

这是一个经典的面试陷阱题。虚拟 DOM 不比直接操作原生 DOM 快。如果你手写了最优的 DOM 操作代码,它一定比虚拟 DOM 快——因为虚拟 DOM 多了一层 JS 对象的创建和 diff 计算。

那虚拟 DOM 的价值是什么?

虚拟 DOM 的价值不是”快”,而是在保证足够好的性能的同时,提供了极大的开发体验提升。

Q5: 虚拟 DOM 快在哪里?慢在哪里?

虚拟 DOM 的”快”就像你拿着购物清单去超市——你不会每拿一件商品就去收银台结一次账(批量更新),而是把所有商品放进购物车,最后一次性结算。它的”慢”就像你每次购物前都要先列清单、核对库存(创建 JS 对象 + diff 计算),这些额外步骤确实花了时间,但相比你一件件结账的低效,整体还是更省时。

快在哪里

1. 批量更新
2. 最小化 DOM 操作
3. 跨平台能力

慢在哪里

1. JS 对象的创建开销
2. diff 算法的计算开销
3. 内存占用

性能对比表


Q6: 为什么不直接干掉虚拟 DOM?

虚拟 DOM 就像一个”通用翻译器”——它把你说的话(JSX/模板)翻译成一种中间语言(VNode),然后由不同的翻译官(渲染器)翻译成不同国家的语言(DOM、Native、SSR、Canvas)。如果去掉翻译器,你就得为每个国家单独学一门语言(为每个平台写不同的渲染代码)。声明式编程、组件化架构、跨平台渲染都需要这个”中间层”作为桥梁。

虚拟 DOM 的不可替代性

1. 声明式编程的基石
声明式编程让开发者不需要关心 DOM 操作的细节,框架自动帮你计算最优的更新方式。 2. 组件化架构的基础
3. 跨平台渲染

有没有比虚拟 DOM 更好的方案?

Svelte 的编译时方案 Svelte 不用虚拟 DOM,而是在编译时把组件转换为直接操作 DOM 的代码。
Svelte 的优势是:没有运行时开销,bundle 更小。劣势是:大型应用的更新性能不如 Vue 3 的编译时优化(PatchFlag、Block Tree)。 Vue 3 的编译时优化 Vue 3 虽然保留了虚拟 DOM,但通过编译时优化大幅减少了运行时开销:

结论

回答”虚拟 DOM 比原生 DOM 快吗”时,先说”不是”,然后解释虚拟 DOM 的价值不是”快”而是”开发体验 + 足够好的性能”。最后提一句 Vue 3 的编译时优化让它接近手写 DOM 的性能。这个回答展示了你对框架设计的深入理解。

延伸阅读与引用参考