探索 Virtual DOM 的前世今生
原文从 Virtual DOM 的动机、Diff 策略、Snabbdom 和 React Fiber 展开。本文保留这条历史与源码学习路线,并修正“Virtual DOM 一定提升 DOM 性能”“Fiber 是轻量线程”“Diff 一定是 O(n)”等容易误导的表述。Snabbdom 和 React 的源码实现也会随版本变化,示例以当前概念和历史版本差异为边界。
一、Virtual DOM 是什么
Virtual DOM(VDOM)不是浏览器标准,也不是唯一固定的数据结构,而是一种用 JavaScript 对象描述 UI,再由 renderer 同步到真实宿主环境的模式:
const vnode = {
type: 'div',
props: { id: 'app' },
children: [
{ type: 'span', children: 'Hello' },
],
}
renderer 可以遍历 VNode 树创建真实节点,这个过程通常称为 mount;当有新旧两棵 VNode 树时,renderer 可以比较并提交变化,这个过程通常称为 patch、diff 或 reconciliation。

Virtual DOM 的主要价值是:
- 以声明式方式表达 UI;
- 让组件和子树可以组合;
- 把直接 DOM 操作集中到 renderer;
- 让同一套协调逻辑有机会适配 DOM、Native、Canvas 或其他宿主环境;
- 结合编译器、响应式系统和调度器减少不必要工作。
它不是“内存中每一个 DOM 节点都永远有一个一一对应的副本”,也不保证比手写 DOM 更快。VNode 创建、Diff、属性更新、组件调用和内存分配都有成本;是否更快要看更新范围、编译优化、浏览器布局/绘制和真实设备测量。
React 早期确实极大推动了 Virtual DOM 和数据驱动组件开发的普及,但这类思想并非凭空由 React 首次创造,Snabbdom、Mithril、早期 MV* 框架和其他 UI 系统也有相近路线。
二、为什么需要 Diff
浏览器 DOM 更新可能引起样式计算、布局、绘制或合成,但“DOM 是性能最大瓶颈”不是所有页面都成立。大量 JavaScript、网络、图片解码和布局触发同样可能成为瓶颈。
声明式 UI 的问题是:状态变化后,应用需要知道哪些真实节点应该更新。如果每次都销毁并重建整棵 DOM,工作量可能很大;如果业务代码手动维护所有增删改,又会失去声明式开发的便利。
Diff 的任务就是在新旧 UI 描述之间找出合理的更新操作:
旧 VNode 树 + 新 VNode 树
↓
reconciliation / diff
↓
创建、更新、移动、删除操作
↓
renderer 提交
一般树编辑距离问题的通用算法可能达到 O(n³) 级别。Web UI 框架通常基于业务场景作启发式假设,把问题限制为可接受的复杂度:
- 主要比较同层节点,不尝试任意跨层移动;
- 不同类型的节点通常代表不同子树,直接替换;
- 列表通过 key 表达节点身份;
- 编译器提供静态节点和动态节点提示。
这些假设能在常见 UI 场景中取得很好的效果,但不是对所有树变化的理论最优保证。

三、Diff 的三条常见策略
1. 按层级比较
如果旧树和新树的根节点都表示一个 div,renderer 通常比较它们的属性和同层 children,而不会尝试把旧树某个深层节点任意搬到新树另一个层级。这样可以把通用树编辑问题转化为更适合 UI 的局部比较。
2. 按类型比较
节点类型改变时,旧子树通常没有必要和新子树逐个比较:
旧:<div>...</div>
新:<section>...</section>
常见做法是卸载旧树、创建新树。类型相同才继续 patch 属性和 children。

“类型”在不同实现中表示不同字段:Vue VNode 可能使用 type、shapeFlag 等信息,Snabbdom 使用 selector sel,React 使用元素/组件类型和 key。不能把一种库的 sameVnode 代码直接套到另一种库。
3. 列表 Diff 和 key
列表中的插入、删除和排序需要知道业务实体是否还是同一个节点:
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
没有 key 时,Vue 等框架可能采用就地 patch;有稳定 key 时,可以建立旧节点到新位置的映射并移动正确节点。key 表达的是身份,不是“只渲染新节点”的开关,也不保证每次只执行一条 DOM 操作。

四、Vue 中的 Virtual DOM
Vue 3 的渲染链路是:
template
↓ parse / transform / codegen
render function
↓ ReactiveEffect 执行
VNode
↓ mount / patch
DOM
组件首次挂载时,render effect 读取响应式依赖;依赖变化后,scheduler 安排组件更新,新的 render 结果与旧 VNode 交给 renderer patch。

Vue 3 还利用 compiler 和 runtime 紧密配合进行优化:
- 静态节点提升;
- Patch Flags;
- Block Tree 和动态子树扁平化;
- keyed children 的前后缀同步和最长递增子序列;
- SSR hydration 的动态节点快速路径。
所以更准确的说法是:Vue 的 Virtual DOM 结合响应式和编译器提示,在很多动态 UI 场景中避免不必要的工作;并不是仅靠一个通用 Diff 就获得所有性能。
五、Snabbdom 的 VNode 与 patch
Snabbdom 是一个独立的 Virtual DOM 库,以小核心、模块化和可扩展性为特点。当前 Snabbdom 3.x 的 README 使用 ESM 形式,历史版本的导入方式可能不同。
import {
classModule,
eventListenersModule,
h,
init,
propsModule,
styleModule,
} from 'snabbdom'
const patch = init([
classModule,
propsModule,
styleModule,
eventListenersModule,
])
const container = document.querySelector('#container')
let vnode = h('div#container', [
h('span', { style: { fontWeight: 'bold' } }, 'Hello'),
h('button', {
on: { click: () => console.log('clicked') },
}, '按钮'),
])
vnode = patch(container, vnode)
const nextVnode = h('div#container', [
h('span', { style: { fontWeight: 'normal' } }, 'Hello Snabbdom'),
h('button', {
on: { click: () => console.log('updated') },
}, '更新后的按钮'),
])
vnode = patch(vnode, nextVnode)
Snabbdom 的 h 用来创建 VNode,init 接收模块并返回 patch。第一次 patch 可以接受真实 DOM 元素,后续调用应传入上一次 patch 返回的 VNode。模块负责 class、props、style、event listeners 等能力。
1. sameVnode
Snabbdom 使用 selector 和 key 等信息判断节点是否可以复用,简化理解为:
function sameVnode(a, b) {
return a.key === b.key && a.sel === b.sel
}
实际实现还会考虑 VNode 结构、组件/元素以及模块 hooks 等边界。不同节点会创建新节点并删除旧节点;相同节点则进入属性和 children 更新。
2. patchVnode 和 children
function patchVnode(oldVnode, vnode) {
vnode.elm = oldVnode.elm
if (vnode.text !== undefined && !vnode.children) {
if (vnode.text !== oldVnode.text) {
vnode.elm.textContent = vnode.text
}
return
}
if (oldVnode.children && vnode.children) {
updateChildren(vnode.elm, oldVnode.children, vnode.children)
} else if (vnode.children) {
addVnodes(vnode.elm, vnode.children)
} else if (oldVnode.children) {
removeVnodes(vnode.elm, oldVnode.children)
}
}
Snabbdom 的 updateChildren 采用与 Vue 2 类似的双端思路:维护旧/新 children 的头尾指针,先尝试头头、尾尾、头尾、尾头匹配;不匹配时借助 key map 查找旧节点,复用并移动,找不到则创建。



当旧节点先遍历完,剩余新节点会被插入;当新节点先遍历完,剩余旧节点会被移除。insertBefore(parent, node, null) 会把节点插入到末尾,但真实实现还需要处理 anchor、模块 hooks、SVG、Fragment 和组件边界。
六、React 的 reconciliation
React 的高层流程可以理解为:
触发更新(初次 render / state update)
↓
render:调用组件,得到新的元素树
↓
reconciliation:比较前后树,计算变化
↓
commit:由 React DOM 等 renderer 提交到宿主环境
↓
浏览器绘制
React 文档强调,render 阶段是调用组件并计算结果,不等于立即修改 DOM;commit 阶段才执行必要的宿主更新。React 可能调用组件函数而最终不产生 DOM 变化。
1. Fiber 的历史和现在
React 16 引入 Fiber 架构,目标之一是把渲染工作拆成可调度的单元,使工作有机会暂停、恢复、重用或放弃,并可以为不同更新分配优先级。Fiber 是 React 内部的工作节点和协调架构,不是操作系统线程,也不能简单等同于 Virtual DOM。
Fiber 的常见概念包括:
child:第一个子 Fiber;sibling:下一个兄弟 Fiber;return:父 Fiber;alternate:current 与 work-in-progress 两棵 Fiber 树之间的对应节点;- update queue:状态更新队列;
- lanes/优先级:现代 React 调度更新的相关信息。

React DOM、React Native 等 renderer 可以共享协调器,但最终提交到宿主环境的方式不同。这也是把 reconciliation 和 rendering 分离的意义。
2. React 更新和 Fiber 树
setState 或 Hook state setter 会把更新排入 React 的更新机制;这不等于“整个应用立即操作 DOM”。React 会执行相关组件的 render,依据类型、位置和 key 进行协调,并在 commit 阶段只提交必要变化。
旧的 React 16 源码中可以看到 current/alternate、updateQueue、expirationTime、effectTag 等字段。现代 React 已经使用 lanes、flags 等不同命名和实现,阅读旧代码时必须注明版本。

3. React 列表协调
React 列表的核心规则与 Vue 类似:
- 不同组件/元素类型通常替换旧子树;
- 同类型且 key/位置匹配时尝试复用;
- key 应稳定、可预测且唯一;
- index 只适合不会重排且没有内部状态的受限列表。
React 旧版 reconcileChildrenArray 先按位置尝试匹配,遇到 key 不匹配后建立剩余旧节点的 map,再处理移动、插入和删除。现代源码仍保留相近的高层策略,但函数签名、字段和调度机制已经改变,不应直接复制旧版本中带 expirationTime 的 Flow 代码。
4. shouldComponentUpdate 与现代优化
类组件可以使用 shouldComponentUpdate、PureComponent 做更新跳过;函数组件通常使用拆分组件、稳定 props、memo 和正确的状态边界。它们都是优化手段,不是改变 React 状态语义的方式。

React Strict Mode、并发渲染和开发环境重复调用可能暴露不纯的 render 逻辑,因此组件 render 应保持纯函数:相同输入得到相同描述,不在 render 中修改外部可变状态。
七、Virtual DOM 的边界和取舍
优点
- 声明式 UI 更容易组合和推理;
- renderer 统一处理挂载和更新;
- key、编译标记和组件边界可以优化更新;
- 协调器与宿主 renderer 分离,便于跨平台;
- 对于复杂动态 UI,减少手写 DOM 状态同步的负担。
成本
- 创建 VNode/元素描述有内存和 CPU 成本;
- Diff 采用启发式假设,不是通用最优树编辑;
- 组件函数、computed、watcher/effect 仍可能执行;
- 不正确的 key、过大的更新边界和不稳定 props 会放大成本;
- 服务器渲染还要考虑序列化、hydration 和客户端一致性。
因此,不能把 Virtual DOM 概括为“避免所有 DOM 操作”或“一定比手写 DOM 快”。它提供的是一个声明式协调层,实际性能要结合编译器、响应式、scheduler、DOM 操作和真实测量判断。
总结
- Virtual DOM 是描述 UI 并由 renderer 同步到宿主环境的一种模式;
- Diff 通常利用同层比较、类型假设和 key,把通用树比较限制到常见 UI 场景;
- Vue 2/Snabbdom 常见双端 children Diff,Vue 3 还结合 Patch Flags、Block Tree 和 LIS;
- React Fiber 是协调和调度架构,不是线程,也不等于整个 Virtual DOM;
- 当前 React 应区分 trigger、render、reconciliation 和 commit;
- key 表达身份,不能把它理解成只更新新增项的性能按钮;
- 使用 Virtual DOM 的价值和成本都要结合具体更新模式与测量结果判断。
参考资料
- Vue 3 渲染机制
- Vue 3
keyAPI - Vue 2 渲染函数
- Snabbdom 当前 README
- React Render and Commit
- React Preserving and Resetting State
- React Fiber Architecture(历史设计文档)
原文作者:小鱼儿。原文关于 Virtual DOM 动机、Diff 三条策略、Snabbdom、React reconciliation 和 Fiber 的主线予以保留;影视图片、知乎跳转噪声、过时源码的绝对化结论、把 Fiber 说成线程以及把 Virtual DOM 说成必然更快的表述已清理或修正。