虚拟DOM原理

113 阅读10分钟

1. 什么是虚拟 DOM?

简短定义:虚拟 DOM(Virtual DOM,简称 vDOM)是一种用 JavaScript 对真实 DOM(浏览器 DOM)进行抽象表示的数据结构(通常是 JavaScript 对象树)。它代表了 UI 的“理想状态”。在需要更新 UI 时,框架先在虚拟 DOM 上进行变更、比较(diff),最后把最小必要的修改批量应用到真实 DOM,以减少真实 DOM 操作开销。

本质:把 DOM 视作一个纯数据结构(树形对象),将对 DOM 的更改先在内存中算好差异,再把差异(patch)映射到真实 DOM。


2. 为什么会有虚拟 DOM?(动机与历史)

  1. 真实 DOM 操作昂贵
  • 浏览器的 DOM 操作通常会触发重排(reflow)与重绘(repaint),尤其是频繁或逐节点地修改,会造成性能瓶颈。
  1. 声明式 UI 思想的需要
  • React 等库主张:声明“UI 应该是什么样子”,而不是手动写变更步骤。把视图描述成数据,框架负责最小化变更。
  1. 复杂交互与多组件更新
  • 在组件树中一次状态变化可能影响很多组件,手工管理 DOM 更新很容易出错且难以优化。
  1. 跨平台抽象
  • 虚拟 DOM 作为中间层,便于将相同的渲染逻辑映射到不同平台(浏览器、Native、Canvas 等)。

历史参考:React(2013)普及了虚拟 DOM 思想,但并非第一个概念性实现。其核心价值是把比对与最小化更新作为框架的职责,从而让开发者写声明式代码。


3. 虚拟 DOM 在 React 中的角色(高层流程)

  1. 组件/函数执行 ——> 返回一个描述 UI 的 虚拟 DOM 树(React 的 JSX 最终被转换为 React.createElement 调用,构成元素对象)。
  1. 框架把新旧虚拟 DOM 做 diff(比较)——> 生成“补丁”(patches)。
  1. 框架把补丁应用到 真实 DOM ——> 最小化 DOM 操作。

流程图表示:


4. 虚拟 DOM 的数据结构(示例)

虚拟 DOM 节点通常是一个 JS 对象,示例(伪):

const vnode = {
  type: 'div',               // DOM 节点类型 / 或者组件函数
  props: { id: 'app' },
  children: [
    { type: 'h1', props: {}, children: ['Hello'] },
    { type: 'p', props: {}, children: ['world'] }
  ],
  key: null                  // 用于列表 diff 优化
}

React 的元素(React element)也是不可变的对象( { type, props, key, ref } )。


5. diff 算法简要(核心思想与常见策略)

目标:在 O(n) 或尽量低的成本下找出旧树到新树的最小变更(理想上最小化真实 DOM 操作)。

常见策略:

  • 树形遍历:从根节点开始深度优先比较。
  • 同层比较:在同一个父节点下比较其 children 列表(用 key 优化)。
  • 类型不同就替换:如果节点类型(tag/组件)不同,则替换整个子树(简单且高效)。
  • key 用于稳定标识:在同层的列表中,key 用于匹配“相同”的元素,减少移动/删除/重新创建。
  • 最小移动算法:计算最长公共子序列(LCS)或使用“最长递增子序列”(LIS)来最小化 DOM 移动。

React 的早期 diff 是启发式的,主要规则是:

  • 不同类型 → 整个替换
  • 同类型(原生 DOM)→ 比较 props 并递归 children(key 优化)
  • 对于列表,使用 key 来决定是否重用节点与移动

注意:最优的通用 diff(树形最小编辑距离)是 NP 难或成本极高,工程上常用启发式规则平衡性能与复杂度。


6. React 中如果没有虚拟 DOM,会怎样?

可能替代方式及缺点:

  1. 逐 DOM 操作的命令式更新(手工)
  • 可行但会导致大量手工逻辑、易错、维护困难。例如 jQuery 风格:每次状态变更手动修改 DOM 元素。
  1. 模板 + 最小局部更新(如直接 DOM patching)
  • 一些框架(例如早期的部分库或基于模板的框架)通过编译时分析产生最小 DOM 更新代码。这需要复杂的编译时静态分析,也限制了表达能力(动态性)。
  1. 直接操作真实 DOM(没有 diff 中间层)
  • 会频繁触发浏览器回流/重绘,导致性能下降。

结论:没有虚拟 DOM,要么牺牲开发效率和可维护性,要么在运行时/编译时付出更复杂的成本。虚拟 DOM 提供了声明式 API 的同时保留了性能优化的可能性。


7. 虚拟 DOM 的优缺点(权衡)

优点:

  • 开发者用声明式写法,不用关心 DOM 更新细节。
  • 可以把大量更新合并,减少真实 DOM 操作。
  • 易于跨平台(React Native、服务器端渲染)。
  • 结构化的 diff 与重用策略使得更新更可控。

缺点 / 局限:

  • 内存与 GC:生成新的虚拟 DOM 树会产生临时对象,GC 代价不可忽视(尤其在大量频繁更新时)。
  • CPU 成本:生成和 diff 虚拟 DOM 需要 CPU。对超高频率更新的场景(如每帧 60fps 的动画)可能成为瓶颈。
  • 不总是最小化渲染:在某些场景下,vDOM diff 产生的 patch 可能不是最优(例如复杂节点的频繁替换)。
  • 学习与调优成本:需要理解 key、不可变数据、渲染副作用等,才能避免性能坑。
  • 无法完全避免布局抖动:即使减少了 DOM 操作,某些 CSS 布局和测量依然会触发回流。

8. Graphical Illustration(树对比示例)

把下面 mermaid 片段复制到 mermaid 渲染工具可视化:

解读:旧树有 [a, b, c],新树 [b, d, c]。有 key 的话 diff 能识别出 b 的重用、a 的删除、d 的插入、c 的位置可能需要移动。


9. 代码示例:React 的更新流程(简化版)详细版 => React更新流程

function App({ count }) {
  return <div>{count}</div>;
}

// 初始 render
ReactDOM.render(<App count={0} />, root);

// 更新
ReactDOM.render(<App count={1} />, root);

内部(非常简化)

  1. render() 生成 element 对象 { type: App, props: { count: 1 } }
  1. React 创建 fiber/vnode,调用 App,得到新的 vnode(例如 { type: 'div', props: { children: '1' } })
  1. React 将新 vnode 与旧 vnode diff,发现 text 内容不同
  1. React 只更新对应的 text 节点(最小化 DOM 操作)

10. 常见的性能问题与定位方法

问题场景:

  • 页面频繁重渲染(不必要的 rerender)
  • 列表中大量元素导致 diff 成本高
  • 大量短生命周期元素导致频繁 GC
  • 在渲染过程中做同步 DOM 测量(如 offsetWidth)导致强制布局

定位方法:

  • React Profiler(内置或 DevTools)查看哪些组件渲染最频繁/耗时。
  • 浏览器 Performance 面板(记录帧、重排、脚本时间)。
  • console.time / 自定义计时。
  • 使用 why-did-you-render(第三方)帮助识别无意义的渲染。

11. 优化策略(从代码到架构)

下面按从小到大、从代码级到架构级分类:

11.1 代码级优化(首选)

  • 避免不必要的渲染
  • React.memo(函数组件) / PureComponent(类组件)。
  • 在 class 组件实现 shouldComponentUpdate。
  • 稳定 props 引用
  • 避免在 render 中每次都创建新对象/函数:使用 useCallback、useMemo 或把常量移出组件。
  • 正确使用 key
  • 列表元素使用稳定、唯一的 key(不要使用索引,除非列表不变)。
  • 分割组件
  • 把大组件拆成更细粒度的纯组件,减少渲染范围。
  • 避免深度对象比较
  • 使用不可变数据(immutable)或浅比较策略,方便复用。
  • 批处理更新
  • React 自动批量更新事件处理器中的 setState;在某些场景(异步回调)使用 unstable_batchedUpdates(低层)或确保批处理。
  • 惰性渲染
  • React.lazy + Suspense 用于按需加载组件。

11.2 渲染/数据层优化

  • 虚拟化(windowing)  对长列表使用 react-window / react-virtualized,只渲染可视区域 DOM 节点,极大减少节点数和 diff 成本。
  • 避免过多 DOM 节点 在需要时合并元素、减少嵌套深度。
  • 减少重排/强制同步布局 把读布局(读取 DOM 尺寸)和写布局(修改 DOM)分离,避免交叉读写导致多次重排。

11.3 架构/运行时级优化

  • 使用不可变数据结构(便于浅比较) 例如 immer、immutable.js,或者保证用新的引用替代修改原对象。
  • 服务端渲染 + Hydration 优化 初始服务器渲染能提升首屏,注意 hydration 阶段的开销。
  • 并发模式 / 渐进渲染(React Concurrent features) 使用 startTransition、useDeferredValue,把低优先级更新延后,不阻塞交互。
  • 减少虚拟 DOM 生成开销 在极端性能场景下可以用手写 DOM 操作或使用更底层的渲染策略(比如直接操作 canvas、WebGL 或编译时生成更细化的 DOM 更新逻辑)。
  • 避免频繁生成临时 vnode 在热点路径减少临时对象分配,或者复用节点(谨慎)。

12. 具体优化示例与对比

场景:列表项包含复杂子组件,父组件频繁更新(父状态与子无关)

错误示例:子组件每次随父 render 都重新渲染

function Parent({ count }) {
  return (
    <div>
      <Toolbar />      // 不依赖 count,但每次都会渲染
      <div>{count}</div>
    </div>
  );
}

优化

const Toolbar = React.memo(function Toolbar() {
  // 纯组件,不依赖父 state
  return <div>toolbar</div>;
});

进一步:确保没有在父 render 中传入变化的 props(如内联函数),否则 memo 无效:

// 不推荐
<Toolbar onClick={() => doSomething()} />

// 推荐
const handleClick = useCallback(() => doSomething(), []);
<Toolbar onClick={handleClick} />

13. 高级优化技巧(工程化)

  • LIS(最长递增子序列)用于减少 DOM 移动:在列表 diff 中用 L IS 算法计算最少移动。
  • 分级更新(优先级调度) :把高优先级(用户交互)与低优先级(数据渲染)分开处理。
  • 批量 DOM 更新(requestAnimationFrame) :把 DOM 更新放到 requestAnimationFrame,保证每帧只做一批更新。
  • 避免在渲染期间做昂贵计算:将重计算移到 web worker 或缓存结果。
  • 使用 Profiling 覆盖:把热点组件用 Profiler 包裹,获得时间线数据,定向优化。

14. Checklist:在项目中应用虚拟 DOM 优化(实用清单)

  • 使用 React Profiler 找出热渲染组件
  • 对无状态纯子组件应用 React.memo
  • 为列表元素添加稳定 key(避免使用索引)
  • 避免在 render 中创建对象/函数(使用 useMemo / useCallback)
  • 对超长列表使用虚拟化(react-window 等)
  • 避免跨读写 DOM(把读和写集中)
  • 使用不可变数据或浅比较策略,便于复用旧 vnode
  • 在性能敏感处考虑手写最小化 DOM 操作或使用 portal/外部容器
  • 如果渲染密集,考虑替代渲染目标(canvas / WebGL)
  • 评估是否适合使用并发特性(startTransition / Suspense)

15. 常见误区与回答

  • 误区:虚拟 DOM 总是比直接 DOM 操作慢 不是。虚拟 DOM 在大多数 UI 更新场景下通过减少 DOM 操作带来性能提升,但在极端、每帧高更新频率的场景(动画、Canvas 实时渲染)可能不适合。
  • 误区:只要使用 memo 就解决所有渲染问题 memo 只能避免当 props 引用不变时的渲染;若父组件每次传入新对象/函数引用,memo 失效。
  • 误区:key 无所谓,用索引就行 索引会导致重新排序或插入删除时元素被错误重用,除非列表结构绝对稳定。

16. 进阶阅读与研究方向(供拓展)

  • React 源码中的 Fiber 架构(调度、增量渲染)
  • diff 算法的优化策略(LIS、LCS、启发式策略)
  • 编译时优化(如 Svelte 的做法:编译期生成最小 DOM 指令)
  • 并发渲染与 scheduler(优先级、时间切片)
  • 虚拟 DOM 与真正的“无虚拟 DOM”框架比较(性能与复杂度权衡)

17. 总结

  • 虚拟 DOM 是一个用内存对象树来代表 UI 的抽象,目的是把对真实 DOM 的操作最小化,从而提升性能并简化开发。
  • 在 React 中,虚拟 DOM 是连接声明式组件和浏览器渲染的关键层,负责 diff、patch、重用与调度。
  • 虚拟 DOM 不是万能的:它带来内存和 CPU 成本,且在特定场景(如高频动画)可能是瓶颈。工程上需要使用 memo、不可变数据、虚拟化、并发特性等工具进行优化。
  • 最好的策略是测量驱动的优化:用 profiler 找出问题,再按需优化。

18. 附:示意图

组件树 -> vDOM -> diff -> patch -> DOM 的端到端视图: