Skip to content
概述
直接操作 DOM 的开销在大型应用中会变得难以控制。这里的核心问题不在于“虚拟 DOM 比真实 DOM 快”——这条命题本身就不成立。虚拟 DOM 在大部分场景下比精心手写的最小化 DOM 操作更慢,因为它多了一层 diff 计算和一层 patch 调用。真正引入虚拟 DOM 的理由是:当应用状态变得复杂时,人工维护最优 DOM 操作路径的成本是不可持续的。
每次修改 DOM 都可能触发浏览器渲染管线中的一组连锁工作:
JS 修改 DOM
→ 标记 Layout Tree 中的脏节点(样式重算作用域传播)
→ 若修改几何属性 → Layout(回流)→ 整棵子树或全树重新布局
→ Paint(光栅化)→ 受影响区域重新绘制
→ Composite(合成)→ GPU 纹理上传一个看似简单的 element.style.width = '100px' 并不是 O(1) 的赋值。它可能引发:
- 强制同步布局(Forced Synchronous Layout):在读取
element.offsetHeight之后立刻写element.style.height,浏览器必须在读取前同步执行 Layout 才能返回正确的尺寸,这会打断正常的异步渲染管线。 - 布局抖动(Layout Thrashing):在循环中读写交替,每次迭代触发一次完整的同步 Layout,将 O(n log n) 量级的批量布局操作退化为 O(n²)。
- 级联失效:修改一个节点的几何属性 → 父节点 → 兄弟节点 → 整个渲染树重新计算。
手动模式下,开发者需要在每一次状态变更时精确控制这些成本。状态变化路径越多,出现遗漏的概率就越高,最终导致界面卡顿甚至布局错误。
基本概念
虚拟 DOM 并不只是一棵“虚拟节点树”,它的架构围绕三个设计约束展开:
1. 声明式状态到 UI 的映射
state → VNode tree → DOM开发者描述“状态应该是什么”,不再直接调用 DOM API 描述“怎么改”。VNode 树充当中间表示(IR),将开发者与真实 DOM 隔离开。
2. 批量更新(Batching)
在同一事件循环 tick 内的多次状态变更会被合并成一次 diff 加一次 patch:
javascript
// 3 次 setState → 1 次 diff → 1 次 patch → 1 次 Layout
this.setState({ a: 1 });
this.setState({ b: 2 });
this.setState({ c: 3 });
// 真实 DOM 只在最终 patch 阶段被修改一次这种机制消除了手动 DOM 模式下的布局抖动风险。
3. 最小化更新集合
Diff 算法的输出是一组 patch 操作(增、删、改、移动),只作用在实际发生变化的节点上,而非整个渲染树。
工作原理
VNode 结构
VNode 是虚拟 DOM 中的基本单元。以 Vue 3 为例,其核心字段如下(TypeScript 类型示意):
typescript
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 的节点做属性比较,从而将 diff 从“全树遍历 + 全属性比较”缩减为“靶向检查”。dynamicChildren:编译阶段将动态子节点提取为扁平数组,diff 时只遍历该数组,跳过纯静态子树。
React 的 FiberNode 虽然在结构上不同,但目标一致:通过 alternate(双缓冲)、effectTag(副作用标记)、lanes(优先级)等字段将 diff 工作拆分为可中断的增量单元。
Diff 算法
虚拟 DOM 的 diff 从朴素的 O(n³) 树编辑距离降至 O(n),依赖三个启发式假设:
假设 1:同层比较。 只比较同一层级的节点,不处理跨层移动。跨层的节点会被直接销毁重建。
简化后的同层子节点 diff 逻辑大致如下(以 Vue 风格的 keyed 子节点为例):
javascript
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 退化为按位置逐一比对,节点移除、插入或重排会导致大量不必要的销毁和重建。
当新旧子节点列表都包含 key 时,已匹配节点的重排顺序通过最长递增子序列(LIS) 求解,复杂度为 O(n log n):
旧: [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 据此决定哪些节点保持原位、哪些需要移动,从而将 DOM 移动操作次数降至最低。
编译时优化:靶向更新
Vue 3 在编译模板时会生成 patchFlag 和 dynamicChildren,运行时不再需要遍历整个 VNode 树:
Block Tree(Vue 3)
→ 每个 Block 维护 dynamicChildren 数组
→ diff 只遍历 dynamicChildren
→ 对每个动态子节点,根据 patchFlag 做靶向 diff
(patchFlag 位掩码指定哪些属性是动态的,其余跳过)静态内容(如 <div class="static">text</div>)在 diff 阶段的成本因此恒定为 O(1)——仅需检查 patchFlag 是否为 0。
注意点
识别不必要的 re-render
- React DevTools:进入 Profiler → 录制一次交互 → 查看 Flame Graph。灰色方块表示未 re-render 的组件(已跳过),黄色/红色方块表示发生了 re-render,颜色越深代表耗时越长。
- Vue DevTools:在 Performance 面板中查看组件渲染时间分布。
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 映射表精确匹配复用和移动。
大列表与虚拟滚动
虚拟 DOM 的 diff 复杂度为 O(n),其中 n 是 VNode 的数量。当列表包含 10,000 个 <li> 时,一次 diff 的耗时可能超过 16 ms,超出单帧预算。虚拟滚动通过只渲染可视区域的节点,将 n 控制在约 20~50 的范围内:
html
<!-- 使用 useVirtualList(VueUse) -->
<div v-for="item in virtualList" :key="item.index">
{{ items[item.index] }}
</div>此时 O(n) 的 diff 只在可视区域内的少量节点上执行,帧时间重新回到安全范围内。虚拟滚动的本质是用“不可见的 VNode 不参与 diff”来降低 n,而不是替换虚拟 DOM。
组件拆分与 Memo
javascript
// ❌ 父组件状态变化 → 全部子组件 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 范围的杠杆。将状态隔离在最小作用域内可以避免 VNode diff 在全组件树中扩散。
限制
虚拟 DOM 并不是唯一解,在运行时性能和编译灵活性之间存在取舍。
与编译时方案的对比:Svelte 和 Solid 等框架在编译阶段直接将状态变更转化为精确的 DOM 操作指令,几乎不再需要运行时 diff。
javascript
// Svelte 编译输入
<script>let count = 0</script>
<button on:click={() => count++}>{count}</button>
// 编译输出(简化)—— 直接操作 DOM
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 表达式均可 | 受限于编译器可静态分析的语法 |
| 跨平台能力 | 统一中间表示 → 多渲染器 | 需为每个平台编写编译器输出目标 |
虚拟 DOM 方案以额外运行时开销换取对动态语言特性的完全兼容(高阶组件、render props、动态 children 等)。编译时方案在性能上占优,但当 UI 动态性过高、超出静态分析能力时,往往需要回退到更粗糙的更新策略。
目前虚拟 DOM 正从“运行时通用方案”向“编译时优化 + 靶向更新”的方向演变。Vue 3 的 patchFlag 和 Block Tree、React Forget(编译时 memo)、Solid 的信号系统都在不断缩小运行时 diff 的覆盖范围。
应用
虚拟 DOM 更稳定的架构价值在于将 UI 描述与平台渲染拆分为独立层:
组件逻辑
│
VNode 树(中间表示)
│
┌───────┼───────┐
│ │ │
DOM Canvas Native
渲染器 渲染器 渲染器
(Web) (Pixi) (React Native)React Native 验证了这一点:同一套 React 组件逻辑和 reconciliation 算法,底层渲染器替换为 iOS 的 RCTView 以及 Android 的 ViewGroup。Weex(Vue → Native)、基于 Canvas 的渲染和终端命令行输出(如 ink)都依赖“VNode 作为中间表示”这一思路。
这意味着当渲染目标发生变化(例如需要同时支持 Web、小程序和 SSR)时,组件代码可以保持不变,只需切换对应的渲染器。
