虚拟 DOM
约 1430 字大约 5 分钟
2026-01-19
DOM 工作原理
浏览器的 JavaScript 引擎、渲染引擎都是 C++ 等底层语言实现的,它们通过 WebIDL 定义“JS 可见接口”,再把 C++ 实现映射到 JS 对象上,让我们能在 JS 中调用 document.createElement、querySelector 等 API。
API 定义
浏览器规范用 WebIDL 描述“哪些接口暴露给 JS、参数和返回值如何转换”。例如:
interface Document { Element createElement(DOMString localName); }C++ 实现与封装
浏览器团队在 C++ 里实现对应接口,并做内存/安全管理:
class Document { public: Element* createElement(const std::string& name) { auto* element = new Element(name); // ...附加到文档树、初始化属性... return element; } };生成绑定代码
WebIDL 编译器会生成“绑定层”,把 JS 调用转成 C++ 调用,并处理类型转换、异常、生命周期:
void Document_createElement(const v8::FunctionCallbackInfo<v8::Value>& args) { auto* doc = UnwrapDocument(args.Holder()); auto name = ToStdString(args[0]); Element* el = doc->createElement(name); args.GetReturnValue().Set(WrapElement(el)); // 返回 JS 可用的包装对象 }注册到 JS 引擎
绑定层把这些回调注册进 V8/SpiderMonkey,JS 里即可直接调用:
const div = document.createElement('div')渲染流水线
当 JS 调用 DOM API 时,调用穿过绑定层进入渲染引擎,更新 DOM 树;随后执行样式计算→布局→分层→绘制→合成,最终显示到屏幕。渲染阶段往往在单线程里串行进行,所以频繁 DOM 操作会放大性能开销。
这个过程说明:JS 侧拿到的“DOM 节点”其实是 C++ 对象的一个 JS 包装;DOM 变更是跨语言、跨线程的,需要尽量减少不必要的调用与重排。
虚拟 DOM 是什么
虚拟 DOM(VNode)是一种“用 JS 对象描述真实界面”的抽象层。一个最小的 VNode 可能长这样:
const vnode = {
type: 'div',
props: { id: 'app' },
children: [
{ type: 'span', children: 'hello' }
]
}VNode 只是数据,直到“渲染器”把它翻译成真实 DOM/平台节点。
为什么要使用虚拟 DOM
控制 DOM 开销
直接操作 DOM 代价高(跨语言、触发布局/绘制)。虚拟 DOM 先在内存里计算新旧树的差异,再把最小变更打包成有限的 DOM 操作。
声明式与可预测
组件声明界面 = 数据的函数;更新时只需重新生成 VNode,具体怎么更新 DOM 由框架的 patch 逻辑负责。
可移植和可扩展
同一套 VNode 可以输出到浏览器、SSR、原生(如 Weex/React Native)或自定义终端;中间还可插入指令、指令集优化、Patch Flag 等编译期/运行期策略。
方便做中间优化
Vue 3 会在编译阶段标记静态节点、收集 Patch Flag,运行时仅触达受影响的部分,减少 diff 成本。
虚拟 DOM 不是免费的抽象:如果页面只有一次渲染且几乎不更新,直写 DOM 可能更直接;当有频繁状态变更和多平台输出需求时,虚拟 DOM 能带来更好的可维护性与性能。
Vue 中的虚拟 DOM 更新链路
模板编译
.vue模板被编译成render函数,产出创建 VNode 的代码,并打上 Patch Flag(标记哪些属性/子节点可能变)。初次挂载
执行
render生成 VNode 树,调用patch(null, vnode, container)创建真实 DOM,绑定属性/事件并插入文档。响应式驱动
reactive/ref变更触发 effect,调度器把更新推入队列(微任务合并),再重新执行render生成新 VNode 树。Patch 过程
patch(oldVnode, newVnode, container)比对新旧 VNode:同类型复用节点并更新属性/事件/子节点,不同类型则卸载旧节点并创建新节点,最后把最小变更提交到 DOM。生命周期与副作用
onMounted/Updated/BeforeUnmount等钩子在 patch 的对应阶段触发;watchEffect等副作用也由调度器控制,避免重复渲染。
Diff 与 Patch 细节(Vue 3)
节点是否同类
只有当
type和key相同,Vue 才会复用旧节点;否则直接卸载再创建。合理使用key是列表性能和稳定性的关键。属性与事件更新
同类节点会逐项比对 props/attrs:新增则设置,变更则更新,删除则移除。事件处理器通过缓存避免重复绑定。
子节点对比
- 文本/空子节点:直接设置
textContent或清空。 - 数组子节点:使用“前后双端指针 + 最长递增子序列”找出最小移动集,最大化 DOM 复用。
- 文本/空子节点:直接设置
组件、Fragment、Teleport、KeepAlive
组件 patch 会对比 props、slots 后调用
render;Fragment 直接复用内层子节点;Teleport 会把渲染输出移动到指定容器;KeepAlive 通过缓存和激活/停用避免重复创建组件实例。列表 key 建议
v-for=\"item in list\" :key=\"item.id\"选择稳定且唯一的 key,避免使用索引;当需要最小移动开销时保持 key 不变,当需要强制刷新时可更换 key。
何时使用/绕过虚拟 DOM
默认使用
常规组件、频繁状态变化、跨平台输出、需要 SSR/水合时,虚拟 DOM 是合理的默认方案。
局部绕过
极端性能场景(超大表格、可视化)可用
ref获取真实 DOM,或使用v-once、v-memo、shallowRef/markRaw减少 diff;必要时借助Teleport、v-show/v-if控制挂载成本。避免不受控操作
尽量不要在框架外直接修改 DOM,否则下一次 patch 可能覆盖你的手动更改;若必须手写 DOM,使用
onUpdated或nextTick确保时机正确。