技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

深入浅出虚拟 DOM 和 Diff 算法:Vue 2 与 Vue 3 的区别

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/90-深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别
本文目录10 个章节
  1. 一、认识虚拟 DOM
  2. 二、Vue 的渲染更新流程
  3. 三、Diff 的基本原则
  4. 四、key 的作用
  5. 五、Vue 2 的 patch 与 updateChildren
  6. 六、Vue 3 的 Diff 与编译器优化
  7. 七、Vue 2 与 Vue 3 的主要差异
  8. 八、如何正确理解 Diff 性能
  9. 总结
  10. 参考资料

深入浅出虚拟 DOM 和 Diff 算法:Vue 2 与 Vue 3 的区别

原文从虚拟 DOM、Vue 2 的 patch/updateChildrenkey,一直讲到 Vue 3 的 patch flag、静态提升和最长递增子序列。本文尽量保留原有章节和示例,修复抓取导致的代码拼接、错误的复杂度和过于绝对的性能结论,并补充 Vue 3.5 当前渲染机制。

一、认识虚拟 DOM

虚拟 DOM(Virtual DOM,VDOM)是用 JavaScript 数据结构表达 UI 的一种方式。例如:

const vnode = {
  type: 'div',
  props: {
    id: 'app',
    class: 'container',
  },
  children: [
    {
      type: 'h1',
      children: '沐华',
    },
  ],
}

不同框架的 vnode 字段并不完全相同:

  • Vue 2 常见字段是 tagdatachildrentextelmkey
  • Vue 3 常见字段是 typepropschildrenkeypatchFlagshapeFlag 等;
  • 上面的对象只是教学模型,不是可以直接交给 Vue renderer 的完整 vnode。

运行时 renderer 可以遍历 vnode 并创建真实 DOM,这一步叫 mount。当有旧 vnode 和新 vnode 时,renderer 可以比较它们,再把必要的差异应用到真实 DOM,这一步叫 patch,也常被称为 diff 或 reconciliation。

真实 DOM 与虚拟 DOM 的差异示意图(已本地化)

虚拟 DOM 的价值不是“天然更快”

原文把“DOM 节点属性很多”和“虚拟 DOM 可以提升性能”联系起来,这个方向有一定道理,但不能推出“VDOM 一定比手写 DOM 快”。更准确的说法是:

  • 直接 DOM 操作可能很快,也可能因为分散、重复、布局读取等原因难以维护;
  • VDOM 为声明式 UI 提供了统一的中间表示,使框架可以集中处理创建、更新、卸载、组件、指令和事件;
  • Diff 可以避免一些不必要的 DOM 操作,但构造 vnode、遍历和比较本身也有成本;
  • Vue 3 还依靠模板编译、静态提升、patch flags、block tree 和批量调度减少运行时工作;
  • 真正的快慢要结合组件规模、模板结构、更新频率和实际 DOM/布局成本测量。

因此,不应把“VDOM 会自动只更新一个节点”或“VDOM 永远比原生 DOM 快”当成结论。

二、Vue 的渲染更新流程

Vue 模板到 DOM 的高层过程可以概括为:

template
  ↓ 编译
render function
  ↓ 执行,读取响应式依赖
new vnode tree
  ↓ renderer
mount / patch
  ↓
real DOM

数据变化时,组件的渲染 effect 重新执行:

响应式数据变化
  ↓
组件更新任务进入调度队列
  ↓
重新执行 render
  ↓
得到 newVnode
  ↓
patch(oldVnode, newVnode)
  ↓
只提交必要的 DOM/组件更新

首次渲染时没有旧 vnode 可以比较,主要是创建和挂载;更新时才需要对比前后 vnode。Vue 2 中通常一个组件对应一个渲染 watcher;Vue 3 中是组件渲染 effect,具体调度和 renderer 实现有所不同。

三、Diff 的基本原则

Diff 是一种更新策略,不是一个唯一标准算法。Vue 2、Vue 3、React 和 Snabbdom 都有相似思想,但实现细节和优化目标不同。

1. 只比较同层级节点

一般不会为了寻找“最相似的树”而跨层级移动节点。比较通常发生在同一个父节点的子节点列表中:

Diff 同层级比较示意图(已本地化)

只比较同层级可以把一个复杂的树匹配问题降为多个局部列表问题。代价是:如果节点从一层移动到另一层,通常会被视为删除旧节点并创建新节点,而不是跨层级搬运。

2. 节点类型不同通常直接替换

旧:<div>...</div>
新:<p>...</p>

当同一位置的 vnode 类型不兼容时,继续对比它们的子树通常没有意义,renderer 会卸载旧节点并挂载新节点:

不同标签类型的节点替换示意图(已本地化)

“类型”在不同框架中可能包含 HTML 标签、组件类型、注释、文本和 Fragment 等,不只是比较字符串标签名。

3. 相同节点继续比较属性和子节点

Vue 2 的 sameVnode 不仅比较 key 和标签,还会考虑注释、数据是否存在、异步组件工厂、input 类型等条件。Vue 3 则使用 vnode type、key、shape 等信息判断 patch 路径。

一个教学版的判断函数可以写成:

function isSameVNode(oldVNode, newVNode) {
  return oldVNode.type === newVNode.type
    && oldVNode.key === newVNode.key
}

这不是 Vue 的完整实现,但能表达核心:key 不是单独决定一切的字段,类型和 key 要共同表达节点身份。

四、key 的作用

假设旧列表为:

[A, B, C, D]

新列表在 B 和 C 之间插入 X:

[A, B, X, C, D]

列表插入元素的 Diff 示意图(已本地化)

1. 有稳定 key 时

如果每项有稳定业务 id:

<li v-for="item in items" :key="item.id">
  {{ item.name }}
</li>

renderer 可以识别 A、B、C、D 仍然是原来的 vnode 身份,只需创建 X,并根据需要移动或更新节点:

使用稳定 key 的列表更新示意图(已本地化)

这不仅关系性能,也关系 DOM 状态:输入框焦点、选中状态、组件实例和过渡状态都可能依赖节点身份。

2. 不要把“不会重新渲染”说得过于绝对

有稳定 key 并不等于对应组件永远不执行更新:

  • 如果该项的 props、插槽或自身响应式状态改变,它仍可能更新;
  • 父组件重新渲染时,子 vnode 仍可能参与必要的 patch;
  • key 的作用主要是帮助 renderer 正确匹配和复用节点,而不是承诺某个组件函数完全不运行;
  • 最终是否产生 DOM 写操作,要看属性、文本和子树是否真的变化。

3. index key 的适用范围

数组下标作为 key 在只追加、只截取且列表项不排序/不插入/不删除中间元素的场景下可能没有问题。但如果列表会重排、插入、删除,index 会随着位置改变,导致旧组件状态被错误复用:

<!-- 可重排列表中不推荐 -->
<li v-for="(item, index) in items" :key="index">
  <input v-model="item.name">
</li>

应优先使用稳定且唯一的业务 id:

<li v-for="item in items" :key="item.id">
  <input v-model="item.name">
</li>

Vue 不会因为所有 v-for 都没有 key 就让应用完全不能运行;但需要 key 的列表、组件状态和过渡场景应遵循框架提示与 lint 规则。没有 key 时,Vue 可能采用按位置复用的策略,这在简单的纯文本列表中可以工作,但语义和状态安全性较弱。

五、Vue 2 的 patchupdateChildren

原文引用的是 Vue 2 源码。下面先还原核心流程,再用简化代码说明双端 Diff。真实源码还包含组件、指令、transition、服务端 hydration、异步组件和模块钩子等大量分支。

1. patch 的职责

oldVnode 不存在,vnode 存在
  → 创建并插入 vnode

vnode 不存在,oldVnode 存在
  → 卸载/删除 oldVnode

两者存在且是同一 vnode
  → patchVnode,更新属性、文本和 children

两者存在但不是同一 vnode
  → 创建新节点,插入旧节点位置,再移除旧节点

Vue 2 的 sameVnode 可在 src/core/vdom/patch.js 中查看。不能把文章中的教学实现当成可直接替换 Vue 内核的完整 patch。

2. patchVnode

同一 vnode 的更新大体是:

function patchVnode(oldVNode, newVNode) {
  const el = newVNode.elm = oldVNode.elm

  // 更新 class、style、attrs、DOM props、事件、指令等模块。
  updateModules(oldVNode, newVNode)

  if (newVNode.text !== undefined) {
    if (newVNode.text !== oldVNode.text) {
      el.textContent = newVNode.text
    }
    return
  }

  const oldChildren = oldVNode.children || []
  const newChildren = newVNode.children || []
  updateChildren(el, oldChildren, newChildren)
}

真实 Vue 2 还要区分文本 vnode、注释 vnode、组件 vnode、静态 vnode、异步占位符和 hydration;上述代码只用于建立心智模型。

3. 双端比较

Vue 2 updateChildren 的核心思路是维护旧列表和新列表的头尾指针,优先尝试四种命中:

  1. 旧头和新头;
  2. 旧尾和新尾;
  3. 旧头和新尾(节点向右移动);
  4. 旧尾和新头(节点向左移动)。

Vue 2 双端 Diff 四种比较情况(已本地化)

教学版伪代码:

function updateChildren(parent, oldChildren, newChildren) {
  let oldStart = 0
  let oldEnd = oldChildren.length - 1
  let newStart = 0
  let newEnd = newChildren.length - 1

  while (oldStart <= oldEnd && newStart <= newEnd) {
    const oldHead = oldChildren[oldStart]
    const oldTail = oldChildren[oldEnd]
    const newHead = newChildren[newStart]
    const newTail = newChildren[newEnd]

    if (isSameVNode(oldHead, newHead)) {
      patchVnode(oldHead, newHead)
      oldStart++
      newStart++
    } else if (isSameVNode(oldTail, newTail)) {
      patchVnode(oldTail, newTail)
      oldEnd--
      newEnd--
    } else if (isSameVNode(oldHead, newTail)) {
      patchVnode(oldHead, newTail)
      moveBefore(parent, oldHead.elm, oldTail.elm.nextSibling)
      oldStart++
      newEnd--
    } else if (isSameVNode(oldTail, newHead)) {
      patchVnode(oldTail, newHead)
      moveBefore(parent, oldTail.elm, oldHead.elm)
      oldEnd--
      newStart++
    } else {
      // 真实 Vue 2 会建立 key -> 旧索引的映射。
      const oldIndex = findByKey(oldChildren, newHead.key, oldStart, oldEnd)

      if (oldIndex === -1) {
        insertBefore(parent, createElm(newHead), oldHead.elm)
      } else {
        const vnodeToMove = oldChildren[oldIndex]
        if (isSameVNode(vnodeToMove, newHead)) {
          patchVnode(vnodeToMove, newHead)
          moveBefore(parent, vnodeToMove.elm, oldHead.elm)
          oldChildren[oldIndex] = undefined
        } else {
          insertBefore(parent, createElm(newHead), oldHead.elm)
        }
      }
      newStart++
    }
  }

  // 新列表还有剩余:创建并插入。
  // 旧列表还有剩余:移除旧节点。
  addRemaining(newChildren, newStart, newEnd)
  removeRemaining(oldChildren, oldStart, oldEnd)
}

真实实现会跳过已经移动的 undefined vnode,使用 createKeyToOldIdx,并根据 removeOnly 控制 transition-group 的移动。双端比较是启发式优化,不保证对任意输入都得到理论上的最少 DOM 操作。

六、Vue 3 的 Diff 与编译器优化

Vue 3 不是简单地“把 Vue 2 的 updateChildren 换个名字”。它结合编译器和运行时,从 vnode 形状、动态节点和子节点类型等方面减少工作。

1. Patch flags

例如:

<div :class="{ active }" />
<input :id="id" :value="value" />
<div>{{ message }}</div>

模板编译器可以在 vnode 上标注动态类型。概念上的生成结果类似:

_createElementVNode(
  'div',
  { class: normalizeClass({ active: _ctx.active }) },
  null,
  2, // CLASS
)

_createElementVNode(
  'div',
  null,
  toDisplayString(_ctx.message),
  1, // TEXT
)

常见标记包括:

const PatchFlags = {
  TEXT: 1,
  CLASS: 1 << 1,
  STYLE: 1 << 2,
  PROPS: 1 << 3,
  FULL_PROPS: 1 << 4,
  HYDRATE_EVENTS: 1 << 5,
  STABLE_FRAGMENT: 1 << 6,
  KEYED_FRAGMENT: 1 << 7,
  UNKEYED_FRAGMENT: 1 << 8,
  NEED_PATCH: 1 << 9,
  DYNAMIC_SLOTS: 1 << 10,
  HOISTED: -1,
  BAIL: -2,
}

实际源码使用位运算判断:

if (vnode.patchFlag & PatchFlags.CLASS) {
  patchClass(el, oldValue, newValue)
}

原文把 patchFlag && PatchFlags.KEYED_FRAGMENT 写成普通逻辑与,这是错误的;判断位标记必须使用 patchFlag & PatchFlags.KEYED_FRAGMENT。此外,标记是编译器和运行时之间的内部契约,不建议业务代码手动构造。

2. 静态提升与 block tree

静态模板节点不会因为每次组件更新而改变。Vue 3 可以:

  • 把静态 vnode 或静态属性对象提升到 render 函数外,只创建一次;
  • 用 patch flag 标记动态文本、class、style 和 props;
  • 以 block 记录动态后代,更新时优先遍历动态节点,而不是完整树;
  • 对稳定 Fragment、keyed Fragment 和 unkeyed Fragment 走不同路径;
  • 在满足条件时使用静态 HTML 字符串等更快的创建路径。

这就是 Vue 3 的 compiler-informed virtual DOM:编译器提前告诉运行时哪里可能变化,运行时因此可以跳过大量静态节点。并不是所有 render function 或完全动态模板都能得到同等优化。

3. 事件缓存的边界

原文把 Vue 3 的事件缓存描述成“所有事件都变成静态节点”,这个说法过于绝对。编译器会根据事件表达式和编译配置生成不同代码,在适合的场景可能使用 _cache 缓存内联处理器;但事件本身仍然要响应最新状态,且不是所有手写 render function 都自动获得该优化。

4. patchKeyedChildren 和最长递增子序列

Vue 3 对 keyed children 的实现通常会:

  1. 从头部同步相同节点;
  2. 从尾部同步相同节点;
  3. 处理新列表剩余部分的纯新增或旧列表剩余部分的纯删除;
  4. 建立新 key 到新索引的映射;
  5. 通过 newIndexToOldIndexMap 记录旧节点位置;
  6. 对仍可复用的节点求最长递增子序列(LIS);
  7. 逆序遍历,根据锚点执行移动、新增或删除。

例如:

旧: [a, b, c, d, e, f, g]
新: [a, b, f, c, d, e, h, g]

头部得到 [a, b],尾部得到 [g]。中间的新节点 [f, c, d, e, h] 对应旧数组位置时,Vue 的内部映射使用“旧索引 + 1”,概念上可表示为:

[f, c, d, e, h]
[6, 3, 4, 5, 0]

其中 0 表示新增节点;[3, 4, 5] 对应的 [c, d, e] 是递增子序列。它们可以尽量保持原位置,其他节点再移动或创建。

Vue 3 keyed children 的 Diff 示意图(已本地化)

LIS 的目标是减少不必要的移动,不代表每种输入都能得到全局最优的 DOM 操作,也不意味着所有节点都不会重新执行组件逻辑。

七、Vue 2 与 Vue 3 的主要差异

方面Vue 2Vue 3
渲染 watcher/effect组件通常对应一个渲染 Watcher组件渲染 effect + scheduler
子节点 DiffupdateChildren 双端比较、key 映射patchKeyedChildren,结合编译信息和 LIS
编译优化主要依赖运行时 patchpatch flags、静态提升、block tree
静态节点更新时仍有较多运行时工作可以缓存并跳过静态子树
Fragment组件模板通常单根原生支持多根节点
vnode 内部字段tag/data/children/elmtype/props/shapeFlag/patchFlag
事件/属性更新由模块钩子处理由 renderer 和 patch flags 分流

Vue 3 的性能提升来自多个层面,不能用固定的“更新快 1.3~2 倍、SSR 快 2~3 倍”概括。性能数据必须注明版本、浏览器、模板、数据规模和测试方式。

八、如何正确理解 Diff 性能

  1. Diff 不会把任意新旧树做全局最优匹配,而是使用类型、key、同层级和列表启发式;
  2. key 首先表达 vnode/业务身份,其次才帮助减少移动和错误复用;
  3. 编译器能提供优化提示时,Vue 3 可以跳过更多静态节点;
  4. 组件更新、子组件 props、slot、响应式 effect 和 DOM 更新是不同层次的问题;
  5. 只减少 DOM 操作不代表 JavaScript、网络、布局和绘制成本都减少;
  6. 大列表应结合稳定 key、分页/虚拟列表、轻量行组件和实际性能分析。

总结

  • VDOM 是 UI 的内存表示,mount 创建 DOM,patch 根据新旧 vnode 应用更新;
  • Diff 通常只比较同层级,并以类型和 key 判断节点身份;
  • Vue 2 的核心是 patchVnode、双端 updateChildren 和 key 映射;
  • Vue 3 的 keyed Diff 使用头尾同步、key 映射和 LIS,同时借助 patch flags、静态提升和 block tree;
  • 稳定 key 能正确复用组件实例和 DOM 状态,但不保证组件永远不更新;
  • VDOM 没有天然性能保证,Vue 的优势来自编译器、运行时、调度器和 renderer 的协同。

参考资料

原文作者:沐华。原文关于 VDOM、同层比较、key、Vue 2 双端 Diff、Vue 3 patch flags、静态提升和 LIS 的主线予以保留;抓取的远程图片已本地化,拼接损坏的源码、错误的 10^3/10^9 绝对复杂度、无 key 必然报错、所有节点只更新一次以及固定性能倍数等说法已删除或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS