什么是虚拟 DOM?Vue 3 的 diff 算法是怎样的?

深入高频原理约 6 分钟读完

一句话回答

虚拟 DOM 是用 JS 对象描述的 DOM 结构。状态变化时,Vue 生成新的虚拟 DOM 树,和旧树对比(diff)找出差异,只把必要的修改应用到真实 DOM 上。diff 只比较同一层级,类型不同就直接替换;带 key 的子节点列表先比较头尾相同的部分,再处理新增和删除,最后对乱序的中间部分求最长递增子序列,让 DOM 移动的次数最少。

详细解析

虚拟 DOM 是什么

一个虚拟节点(VNode)就是一个普通的 JS 对象:

JavaScript
// <div id="app"><p>hello</p></div> 对应的 VNode(简化)
const vnode = {
  type: 'div', // 标签名;如果是组件,就是组件对象
  props: { id: 'app' },
  children: [{ type: 'p', props: null, children: 'hello' }],
  key: null,
  el: null, // 挂载后指向真实 DOM
  // Vue 3 新增的标记
  shapeFlag: 17, // ELEMENT | ARRAY_CHILDREN:元素节点,子节点是数组
  patchFlag: 0, // 编译器标记的动态内容,0 表示没有标记
}

为什么需要虚拟 DOM

  • 声明式开发:开发者只描述"什么状态对应什么 UI",由框架计算差异、更新 DOM,不用手动操作 DOM
  • 跨平台:VNode 不依赖浏览器环境,同一套渲染逻辑可以输出到浏览器 DOM,也可以在服务端渲染成 HTML 字符串,或者通过自定义渲染器(createRenderer)输出到 Canvas 等平台
  • 批量更新:配合异步更新队列,多次状态修改只触发一次重新渲染,对比之后统一更新 DOM,详见 nextTick 的原理

diff 的基本策略

  • 只比较同一层级:实际开发中很少把节点跨层级移动,只做同层比较,能把完整对比两棵树的 O(n³) 复杂度降到接近 O(n)。节点跨层级移动时,会被当作删除再新建
  • 类型不同直接替换:标签名或组件不同,就卸载旧节点、挂载新节点,不再比较它们的子节点
  • 类型和 key 都相同才复用:复用真实 DOM,再继续比较 props 和子节点。同一层的子节点通过 key 识别,见 key 的作用

Vue 3 带 key 子节点的 diff

新旧子节点都是带 key 的数组时,分五步处理:

  1. 从头部开始比较:相同的节点直接 patch,遇到不同的就停下
  2. 从尾部开始比较:同样处理,遇到不同的就停下
  3. 旧节点处理完、新节点还有剩余:剩下的都是新增节点,依次挂载
  4. 新节点处理完、旧节点还有剩余:剩下的旧节点全部卸载
  5. 处理中间乱序的部分:
    • 为新节点建立 key 到索引的映射
    • 遍历旧节点:能在映射里找到的就 patch 复用,找不到的就卸载;同时记录每个新节点在旧列表中的位置
    • 对这个位置数组求最长递增子序列。属于子序列的节点相对顺序没变,不用移动;其余节点移动到正确的位置,在旧列表中找不到的就新建

如果复用的节点在新列表中的顺序本来就是递增的,说明不需要移动,Vue 会跳过求子序列这一步。

举个例子

文本
旧:a b c d e f g
新:a b e c d h f g

1. 头部:a、b 相同,patch 后停在 c 和 e
2. 尾部:g、f 相同,patch 后停在 e 和 h
3、4. 新旧两边都有剩余,跳过
5. 中间部分:旧 [c d e],新 [e c d h]
   e、c、d 在旧列表中的位置分别是 4、2、3,h 是新节点
   [4, 2, 3] 的最长递增子序列是 [2, 3],对应 c、d,它们不用移动
   最终只需要两次 DOM 操作:把 e 移到 c 前面,新建 h 插到 f 前面

源码中位置数组存的是"旧索引 + 1",用 0 表示新节点,所以这个例子里实际是 [5, 3, 4, 0]。

Vue 2 的双端比较

Vue 2 用四个指针分别指向新旧列表的头和尾,每一轮依次比较"旧头和新头、旧尾和新尾、旧头和新尾、旧尾和新头",匹配上就 patch 并移动指针,交叉匹配时还要移动 DOM。四种情况都不匹配时,再用 key 去旧节点里查找。

编译优化的配合

上面是完整 diff 的过程。Vue 3 的编译器还会给动态节点打上 PatchFlag,并把动态节点收集到 Block 中,更新时只比较这些动态节点,静态内容直接跳过,详见 Vue 3 在编译阶段做了哪些优化。

面试官可能追问

为什么要求最长递增子序列?

位置数组记录的是新节点在旧列表中的位置。递增的部分说明这些节点在新旧列表中的相对顺序没变,可以原地不动,其余节点围绕它们移动或插入就行。子序列越长,要移动的节点越少,所以求最长递增子序列,就是在求最少的 DOM 移动次数。Vue 用贪心加二分查找来求,时间复杂度是 O(n log n)。

虚拟 DOM 一定比直接操作 DOM 快吗?

不一定。虚拟 DOM 多了创建 VNode 和 diff 的开销,针对具体场景手写、经过优化的 DOM 操作理论上可以更快。它的价值在于:开发者不用手动管理 DOM,就能在大多数场景下获得不错的性能,是在开发效率和性能之间取得平衡;它还为跨平台渲染提供了统一的抽象。

Vapor Mode 是什么?

Vue 团队推出的一种可选的编译模式,思路受到 Solid.js 的启发。它把模板直接编译成创建和更新真实 DOM 的代码,运行时不再创建虚拟 DOM,也就不需要对比新旧两棵虚拟 DOM 树:响应式数据变化时,直接更新依赖它的那个 DOM 节点,从而减少运行时的开销。它是可选的,不需要的项目可以继续使用基于虚拟 DOM 的模式。

易错点

  • diff 只做同层比较,节点跨层级移动会被当作删除再新建
  • 同一个节点的判断条件是类型和 key 都相同,key 相同但标签不同也会重新创建
  • 最长递增子序列找的是不需要移动的节点,不是需要移动的节点

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。