Skip to content

为什么要引入虚拟 DOM

问题源起:直接操作 DOM 的成本模型

虚拟 DOM 存在的必要性不能从"虚拟 DOM 比真实 DOM 快"来论证——这条命题本身是错的。虚拟 DOM 在绝大多数场景下比精心手写的 DOM 操作更慢,因为它多了一层 diff 计算 + 一层 patch 调用。

真正的论证起点是:在大规模应用中,人工维护最优 DOM 操作路径的成本是不可持续的

真实 DOM 操作的代价构成

每次 DOM 修改在浏览器渲染管线中可能触发:

text
JS 修改 DOM
  → 标记 Layout Tree 脏节点(Style Recalc 作用域传播)
    → 若修改几何属性 → Layout(Reflow)→ 子树或全树重新布局
      → Paint(光栅化)→ 受影响区域重新绘制
        → Composite(合成)→ GPU 纹理上传

一个 element.style.width = '100px' 的代价不是 O(1) 的赋值操作。它可能触发:

  1. 强制同步布局(Forced Synchronous Layout):读取 element.offsetHeight 紧接着写 element.style.height,浏览器必须先同步执行 Layout 才能返回正确的 offsetHeight,打断正常的异步渲染管线
  2. 布局抖动(Layout Thrashing):循环中读写交替,每次迭代触发一次完整的同步 Layout,将 O(n log n) 的批量布局操作退化为 O(n²)
  3. 级联失效:修改一个节点的几何属性 → 父节点 → 兄弟节点 → 整个渲染树的重计算

在手动 DOM 操作模式下,开发者需要在每次状态变更时精确控制这些成本——状态变化路径越多,遗漏的概率越高。


虚拟 DOM 的架构定义

虚拟 DOM 的核心不是"虚拟",而是它的三个设计约束:

1. 声明式状态 → UI 映射

text
state → VNode tree → DOM

开发者描述"状态应该是什么",而非"DOM 应该如何改变"。VNode 树作为中间表示(IR)起到缓冲作用——开发者不直接接触 DOM API。

2. 批量更新(Batching)

js
// 3 次 setState → 1 次 diff → 1 次 patch → 1 次 Layout
this.setState({ a: 1 })
this.setState({ b: 2 })
this.setState({ c: 3 })
// 真实 DOM 只在最终 patch 阶段被修改一次

在同一事件循环 tick 内的多次状态变更被合并为一次 diff + patch,消除了手动 DOM 模式下的布局抖动风险。

3. 最小化更新集合

Diff 算法的输出是一组 patch 操作(增/删/改/移动),仅作用于实际变更的节点——而非整个渲染树。


VNode 的结构设计(以 Vue 3 为例)

Vue 3 的 VNode 是一个 VNode 接口实例,核心字段:

ts
interface VNode {
  type: string | Component          // 标签名或组件定义
  props: Record<string, any> | null // 属性(含事件、样式)
  children: VNode[] | string | null // 子节点
  el: Node | null                   // 关联的真实 DOM 节点
  key: string | number | null       // diff 优化的索引
  shapeFlag: number                 // 位运算编码的节点类型标志
  patchFlag: number                 // 编译时生成的动态属性标记(靶向更新)
  dynamicChildren: VNode[] | null   // 编译时提取的动态子节点列表
  // ...
}

关键设计:

  • shapeFlag:位掩码,编码节点类型(ELEMENT / COMPONENT / TEXT_CHILDREN / ARRAY_CHILDREN 等),diff 阶段通过位运算快速判断节点类别,避免字符串比较
  • patchFlag(Vue 3 编译时优化):模板编译阶段静态分析,标记节点上哪些属性是动态绑定的。diff 时跳过无 patchFlag 的静态节点,只在有 flag 的节点上做属性比较——这是 Vue 3 性能提升的核心,将 diff 从"全树遍历 + 全属性比较"缩减为"靶向检查"
  • dynamicChildren:编译阶段将动态子节点提取为扁平数组,diff 时只遍历此数组而非全部 children,跳过纯静态子树

React 的 FiberNode 结构不同但目标一致:通过 alternate(双缓冲)、effectTag(副作用标记)、lanes(优先级)等字段将 diff 工作拆分为可中断的增量单元。


Diff 算法的核心机制

虚拟 DOM diff 从朴素的三次方树编辑距离(O(n³))降至 O(n),依赖于三个启发式假设:

假设 1:同层比较

只比较同层级的节点,不跨层移动。跨层的节点直接销毁重建。

js
// 简化:patchChildren 的同层 diff 逻辑
function patchChildren(prevChildren, nextChildren) {
  // 头头比较
  while (prevHead.key === nextHead.key) { /* 复用,指针前移 */ }
  // 尾尾比较
  while (prevTail.key === nextTail.key) { /* 复用,指针后移 */ }
  // 头尾交叉比较(节点移动场景)
  if (prevHead.key === nextTail.key) { /* 移动节点 */ }
  // 剩余节点 → 进入 key 映射表查找
  const keyMap = buildKeyMap(prevRemaining)
  for (const nextChild of nextRemaining) {
    const prevMatch = keyMap.get(nextChild.key)
    if (prevMatch) { /* 复用 + 移动 */ }
    else { /* 新建 */ }
  }
}

假设 2:相同 type 的节点可复用

type(标签名或组件)相同 → 认为是同一节点,复用 DOM 并仅更新属性。type 不同 → 销毁旧节点,创建新节点。

假设 3:key 标识节点稳定性

key 是 diff 算法的索引——无 key 时,列表 diff 退化为按位置一一比对,节点移除/插入/重排导致大量不必要的销毁和重建。

LIS 算法求最小移动次数

当新旧子节点列表都包含 key 时,已匹配节点的重排次序通过最长递增子序列(LIS, Longest Increasing Subsequence)求解。复杂度 O(n log n):

text
旧: [A, B, C, D]   (key)
新: [A, C, B, D]

匹配后索引序列: [0, 2, 1, 3]
LIS = [0, 2, 3]  →  A, C, D 保持原位不动
非 LIS 节点 → B 需要移动到新位置

Vue 3 的实现中,getSequence() 函数返回 LIS 索引数组,patchKeyedChildren 根据 LIS 判定哪些节点保持原位、哪些需要移动,将 DOM 移动操作次数降至最低。


跨平台渲染解耦的架构价值

虚拟 DOM 的价值不限于浏览器。其架构层的意义在于将"UI 描述"与"平台渲染"拆分为独立层:

js
       Component Logic

       VNode Tree (IR)

  ┌──────────┼──────────┐
  │          │          │
DOM      Canvas     Native
Renderer  Renderer  Renderer
(Web)     (Pixi)    (React Native)

React Native 的实现验证了这一分层:同一套 React 组件逻辑 + 同一套 diff/reconciliation 算法,底层 renderer 替换为 iOS(RCTView)和 Android(ViewGroup)的原生控件映射。Weex(Vue → Native)、Canvas 渲染、终端命令行输出(ink)都基于同样的"VNode 作为 IR"思想。

这是比性能更稳定的架构价值:渲染目标变化时,组件代码仍然是同一份。在需要同时支持 Web + 小程序 + SSR 的业务场景中,虚拟 DOM 的 IR 抽象是唯一的低成本跨平台路径。


编译时优化对运行时 diff 的替代

Vue 3 的靶向更新(patchFlag + Block Tree)

SFC 编译阶段,模板编译器分析动态绑定,生成 patchFlagdynamicChildren。运行时 diff 不再遍历完整 VNode 树,而是:

text
Block Tree(Vue 3)
  → 每个 Block 维护 dynamicChildren 数组
    → diff 仅遍历 dynamicChildren
      → 对每个动态子节点,根据 patchFlag 做靶向 diff
        (patchFlag 位掩码指定哪些属性是动态的,非匹配位直接跳过)

这意味着静态内容(<div class="static">text</div>)在 diff 阶段的成本恒定为 O(1)——只检查 patchFlag 是否为 0。

Svelte / Solid 的激进路线:消除 VDOM

Svelte 编译时直接将状态变更编译为精确的 DOM 操作指令:

js
// Svelte 编译输入
<script>let count = 0</script>
<button on:click={() => count++}>{count}</button>

// 编译输出(简化)
function instance($$self, $$props, $$invalidate) {
  let count = 0
  function click_handler() { $$invalidate(0, count += 1) }
  return [count, click_handler]
}

function create_fragment(ctx) {
  // ... 直接操作 DOM 的指令
  // count 变化 → 精确更新 button.textContent,无 diff
}

Solid 使用 Signal + 细粒度响应式,每个 DOM 绑定是一个独立的 effect,状态变化直接触发对应 DOM 节点的精确更新——diff 被完全消除。

两条路线的分歧本质

维度VDOM 阵营 (React/Vue)编译时阵营 (Svelte/Solid)
更新粒度组件级 → diff 树级节点级 → 直接 DOM 更新
运行时开销diff 算法 + patch极低(几乎无运行时)
编译复杂度低(JSX/SFC 模板直接生成 VNode)高(编译器需分析所有状态绑定路径)
动态性支持任意 JS 表达式均可受限于编译器可静态分析的语法
跨平台统一 IR → 多 renderer需为每个平台编写编译器输出目标

VDOM 方案牺牲了部分运行时性能,换取了对动态语言特性(高阶组件、render props、动态 children)的完全兼容。编译时方案在性能上占优,但面对高动态性的 UI 场景需要回退到更粗糙的更新策略。


生产环境中的虚拟 DOM 调优

识别不必要的 re-render

React DevTools → Profiler → 录制交互 → 查看 Flame Graph:

  • 灰色方块 = 未 re-render 的组件(跳过)
  • 黄色/红色方块 = re-render 的组件,颜色越深耗时越长

Vue DevTools → Performance → Component Render 时间分布。

key 使用不当导致的性能退化

html
<!-- ❌ 使用 index 作为 key — 列表重排后 key 错位,diff 失效 -->
<li v-for="(item, index) in items" :key="index">

<!-- ✅ 使用稳定的唯一标识 -->
<li v-for="item in items" :key="item.id">

key=index 在列表头部插入/删除时,每个后续节点的 key 全部移位,diff 算法判定所有节点都变了,触发全量 DOM 重建。key=item.id 时算法通过 key 映射表精确匹配移动和复用。

大列表虚拟滚动

VDOM 的 diff 复杂度是 O(n),n 为 VNode 数量。10000 个 <li> 的 diff 耗时在 16ms 以上,单帧预算无法容纳。虚拟滚动(virtual scroll)通过仅渲染可视区域的节点将 n 控制在 ~20-50:

html
<!-- VueUse: useVirtualList -->
<div v-for="item in virtualList" :key="item.index">
  {{ items[item.index] }}
</div>

VDOM diff 的 O(n) 代价此时仅在可视区间的 20-50 个节点上执行,帧时间回到安全范围内。虚拟滚动的本质是用"不可见的 VNode 不参与 diff"来削减 n——而不是替换 VDOM 本身。

组件拆分与 React.memo / Vue computed

js
// ❌ 父组件状态变化 → 全部子组件 re-render → 全部 VNode diff
function Parent() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <ExpensiveChild />      {/* 每次 count 变化都 re-render */}
      <button>{count}</button>
    </div>
  )
}

// ✅ React.memo 切断 re-render 传播
const ExpensiveChild = React.memo(function ExpensiveChild() { /*...*/ })
// 或 Vue 中:将 ExpensiveChild 的 props 设为 computed 属性,避免引用变化

组件边界是控制 diff 范围的杠杆——将状态隔离在最小作用域内,可以避免 VDOM diff 在全组件树中传播。


虚拟 DOM 不是终点

虚拟 DOM 的引入是 2013-2015 年框架设计的合理选择——它解决了直接 DOM 操作在复杂应用中的可维护性问题,以可控的运行时开销(diff O(n) + 双 VNode 树内存开销)换取了声明式编程模型和跨平台能力。

当前框架的演进方向表明,虚拟 DOM 正在从"运行时通用方案"向"编译时优化 + 靶向更新"的方向退化。Vue 3 的 patchFlag 和 Block Tree、React Forget(编译时 memo)、Solid 的信号系统都在缩小运行时 diff 的覆盖范围。未来的趋势是:编译时静态分析 + 细粒度响应式覆盖大部分更新场景,VDOM 退化为处理不可静态分析的高动态性 UI 场景的 fallback 策略。