Vue 的虚拟 DOM 和 diff 算法是怎么回事?

2026年 阅读约 9 分钟 面试指南 · 前端面试

深入解析Vue虚拟DOM与diff算法:为什么需要虚拟DOM、Vue2双端对比与Vue3最长递增子序列、key的作用、静态提升与PatchFlags编译优化,分三层讲解。

一句话总结

虚拟 DOM 是用 JS 对象描述真实 DOM 树——数据变化后先在新旧两棵 JS 树之间做 diff,算出最小变更再一次性更新真实 DOM。Vue2 用双端对比(头尾四个指针),Vue3 在编译时预处理静态内容 + 运行时用最长递增子序列尽量少移动节点。key 是节点的身份标识,diff 靠它判断"同一个节点"能否复用。

初级理解

为什么要虚拟 DOM:直接操作 DOM 代价高且逻辑分散。有了 vdom,渲染流程变成:模板 → render 函数 → vdom(JS对象)→ diff → 最小化真实 DOM 操作。好处有二:① 声明式开发,只管数据;② JS 对象可运行在任何环境(SSR、小程序、Canvas),实现跨平台。

// vnode 长这样(简化) { type: 'div', props: { class: 'list', key: 3 }, children: [ { type: 'span', props: null, children: '你好' } ] }

diff 的基本原则:只做同层级比较(不跨层移动),节点 type 和 key 都相同才视为可复用;类型不同直接销毁重建,不再深入比较。

key 的作用:给每个节点一个稳定身份。没有 key 时 Vue 默认"就地复用"——只修补内容不移动节点,这在列表顺序变化时会出现状态错位;错误地用 index 作 key 等于没有 key。

中级深入

Vue2 双端 diff:新旧列表各持头尾两个指针,每轮循环依次尝试四种命中:旧头-新头、旧尾-新尾、旧头-新尾、旧尾-新头;四种都未命中时,把旧列表的 key→index 映射表拿出来查找复用。这个策略对反转、平移这类常见变更非常高效。

// 四种命中(简化示意) isSameVnode(oldStart, newStart) || // ① 头-头:都后移 isSameVnode(oldEnd, newEnd) || // ② 尾-尾:都前移 isSameVnode(oldStart, newEnd) || // ③ 头-尾:旧头移到末尾 isSameVnode(oldEnd, newStart) // ④ 尾-头:旧尾移到开头

Vue3 diff(patchKeyedChildren):先做预处理——从头比头、从尾比尾,把两边相同的部分直接跳过(常见场景:往头部/尾部插入元素,复杂度直接 O(n));剩下的中间乱序部分才进入核心对比:

  • 用新列表的 key 建映射,遍历旧中间部分:能复用的打上"新列表中的位置索引",不能复用的标记删除
  • 对可复用节点求最长递增子序列(LIS)——序列内的节点相对顺序不变,不需要移动;只移动 LIS 之外的节点
核心思想:Vue2 追求"每一轮都尽快命中一种情况";Vue3 追求"尽量不移动,移动得最少"。所以 Vue3 随机乱序场景整体优于 Vue2。

高级拓展

Vue3 的编译时优化(Vue2 完全没有的部分):

  • 静态提升(hoistStatic):纯静态的 vnode 提到 render 函数外只创建一次,diff 时直接跳过
  • PatchFlags:编译时标记 vnode 的动态点(TEXT=只有文本动态 / CLASS / PROPS / STYLE…),diff 只比对标记的字段,不再全量遍历 props
  • Block Tree:模板根节点/分支节点作为 Block 收集所有动态后代节点(拍平结构),更新时只遍历动态节点列表,跳过整棵静态子树
  • 事件缓存 cacheHandlers:inline 事件(如 @click="count++")被缓存,避免每次渲染生成新函数导致子组件无效更新
// 编译产物示意:PatchFlag 1 表示"只有 text 是动态的" export function render(_ctx) { return (_openBlock(), _createElementBlock('div', null, [ _createElementVNode('span', null, _toDisplayString(_ctx.msg), 1 /* TEXT */), _createElementVNode('span', null, '静态内容不会参与diff') // 静态 ])) }

与 React 对比的经典话题:React 运行时不知道哪个组件会变,setState 后整棵子树 re-render(靠 shouldComponentUpdate/memo 手动拦);Vue 在编译时就知道动态点,配合响应式系统的精确依赖,天然做到组件级精准更新。这是"编译时优化"与"运行时优化"两条路线的代表性差异。

注意:虚拟 DOM 的性能不是"永远最快"——对极简静态页,直接 innerHTML 模板编译反而更快;虚拟 DOM 的价值是可维护性 + 可接受的性能下限(声明式框架的性能保底),这个观点说出来会很加分。

实战场景

场景一:index 作 key,输入框内容串行

// 列表每行有 input,删除第一行后: // index key:原第2行 key 从 1 变 0,但节点被"就地复用",input 里用户输入没清 → 内容错位 // 唯一 id key:节点正确复用/销毁,状态跟着数据走 <Row v-for="item in list" :key="item.id" />

场景二:随机 key 强制重建的性能坑

// 反例:每次渲染 key 都不同 → 全量销毁重建,diff 形同虚设 <li v-for="item in list" :key="Math.random()"> // 另一种常见坑:用 Date.now() / 递增计数器当 key,效果相同

场景三:大列表 diff 卡顿

// 1 万条数据插入头部,双端 diff 也救不了全量 patch // 正解:虚拟滚动(只渲染可视区)+ 分页/增量加载 // 组件层面:把静态列抽成纯展示子组件,配合 v-memo 跳过无变化行 <Row v-for="item in visibleList" :key="item.id" v-memo="[item.status]" />

面试模拟

Q:Vue2 和 Vue3 的 diff 算法核心区别?

A:三点——① Vue2 全量进入双端对比,Vue3 先做头尾预处理,常见的前插/后插直接 O(n);② 乱序部分 Vue3 用最长递增子序列让最少的节点移动;③ Vue3 有编译时优化(静态提升、PatchFlags、Block Tree),diff 只碰动态内容,Vue2 是纯运行时全量比对。

Q:为什么不建议用 index 作 key?什么情况下 index 作 key 无害?

A:index 与"位置"绑定而不与"数据"绑定,列表中间插入/删除/排序时,key 不变的节点内容却变了,Vue 复用旧节点导致内部状态(输入值、勾选、滚动、子组件实例)错位。只有列表纯展示、无状态组件、不会中间增删重排时 index 作 key 才无害。

Q:有了虚拟 DOM,为什么 Vue3 还要做大量编译时优化?

A:因为虚拟 DOM 解决的是"声明式的性能下限",而运行时 diff 再快也要遍历节点。Vue3 把"哪些内容是动态的"提前在编译期算好(PatchFlags/Block Tree),运行时直接跳过静态部分,把 diff 从"整棵树"缩小到"动态点集合",这是 React 那套纯运行时方案做不到的。