React 在 2013 年发布时,虚拟 DOM(Virtual DOM)是其最具创新性的设计之一。这个设计让”数据驱动视图”成为前端开发的主流范式。但虚拟 DOM 真的比直接操作 DOM 更快吗?答案并非简单的”是”或”否”。理解虚拟 DOM 的设计动机,需要回到 2013 年的前端开发背景,理解它解决了什么问题,以及它带来的权衡。
一、直接操作 DOM 的性能问题
1.1 DOM 操作的真实成本
很多人认为 DOM 操作慢是因为”DOM 是用 C++ 实现的,应该很快”。问题不在于 DOM 本身的实现,而在于浏览器渲染管线的连锁反应。
一次简单的 DOM 操作可能触发完整的渲染管线:
// 这段代码会导致多少次重排?const container = document.getElementById("container");
for (let i = 0; i < 100; i++) { const div = document.createElement("div"); div.textContent = `Item ${i}`; div.style.height = "50px"; // 触发样式计算 container.appendChild(div); // 触发布局 console.log(container.offsetHeight); // 强制同步布局!}1.2 强制同步布局(Layout Thrashing)
最严重的性能问题来自强制同步布局(Forced Synchronous Layout):
// 性能灾难:读写交替const elements = document.querySelectorAll(".item");elements.forEach(el => { el.style.width = "100px"; // 写 console.log(el.offsetWidth); // 读 - 强制布局!});
// 优化方式:批量读写分离const elements = document.querySelectorAll(".item");const widths = [];
// 先读elements.forEach(el => widths.push(el.offsetWidth));
// 再写elements.forEach((el, i) => { el.style.width = widths[i] + 10 + "px";});1.3 2013 年的前端困境
在 React 发布之前,前端开发面临的核心问题:
当时的主流框架(jQuery、Backbone、Angular 1.x)都采用命令式编程:
// jQuery 时代的典型代码function updateTodoList(todos) { // 手动管理 DOM 更新 const $list = $("#todo-list"); $list.empty(); // 清空
todos.forEach(todo => { const $item = $("<li>").text(todo.text);
if (todo.completed) { $item.addClass("completed"); }
$item.appendTo($list); });
// 更新计数器 const completedCount = todos.filter(t => t.completed).length; $("#completed-count").text(completedCount);
// 更新状态文本 if (todos.length === 0) { $("#empty-state").show(); } else { $("#empty-state").hide(); }}1.4 DOM 操作的性能数据
直接 DOM 操作的实际开销:
| 操作类型 | 时间复杂度 | 实际耗时(1000 次) |
|---|---|---|
createElement | O(1) | ~1ms |
appendChild | O(n) | ~10ms |
innerHTML = ... | O(n) | ~5ms |
element.style.xxx = ... | O(1) | ~0.1ms |
读取 offsetWidth/height | O(n) | ~50ms(强制布局) |
| 修改 class 触发重排 | O(n) | ~20ms |
二、虚拟 DOM 的设计理念
2.1 核心思想:声明式 UI
虚拟 DOM 的核心不是”更快”,而是让声明式 UI 成为可能。
// React 的声明式写法function TodoList({ todos }) { const completedCount = todos.filter(t => t.completed).length;
return ( <div> <ul> {todos.map(todo => ( <li key={todo.id} className={todo.completed ? "completed" : ""}> {todo.text} </li> ))} </ul>
<p>已完成: {completedCount}</p>
{todos.length === 0 && <p>暂无待办事项</p>} </div> );}
// 只需要告诉 React "渲染成什么样"// 不需要关心 "怎么更新"2.2 虚拟 DOM 的本质
虚拟 DOM 是真实 DOM 的 JavaScript 对象表示:
// JSX 转译后的真实代码const element = React.createElement( "div", { className: "container" }, React.createElement("span", null, "Hello"));
// 虚拟 DOM 对象结构{ $$typeof: Symbol(react.element), type: "div", key: null, ref: null, props: { className: "container", children: { type: "span", props: { children: "Hello" } } }}2.3 为什么用 JavaScript 对象表示?
| 特性 | JavaScript 对象 | 真实 DOM |
|---|---|---|
| 创建成本 | 极低 | 较高 |
| 内存占用 | 小 | 大(包含很多属性) |
| 可比较性 | 容易实现 diff | 需要遍历 DOM 树 |
| 跨平台 | 与平台无关 | 浏览器特有 |
| 可序列化 | JSON.stringify | 需要 XML 序列化 |
2.4 虚拟 DOM 的价值主张
三、Diff 算法原理
3.1 Diff 算法的核心假设
React 的 Diff 算法基于三个核心假设,将 O(n³) 复杂度降低到 O(n):
3.2 同层比较策略
React 只比较同一层级的节点,不跨层级比较:
// 同层比较的实现逻辑function reconcileChildren(oldFiber, newElements) { let index = 0; let oldFiber = oldFiber; let prevSibling = null;
while (index < newElements.length || oldFiber != null) { const newElement = newElements[index]; const newFiber = updateSlot(oldFiber, newElement);
// 同一位置比较,不跨层级 if (oldFiber) { oldFiber = oldFiber.sibling; }
index++; }}3.3 key 的作用与原理
key 是 React 追踪列表元素身份的关键:
// 无 key:低效更新<ul> {items.map((item) => ( <li>{item.text}</li> ))}</ul>
// 有 key:高效移动<ul> {items.map((item) => ( <li key={item.id}>{item.text}</li> ))}</ul>3.4 Diff 算法的完整流程
3.5 时间复杂度分析
| 场景 | 朴素 Diff | React Diff | 说明 |
|---|---|---|---|
| 两个相同树 | O(n) | O(n) | 遍历比较 |
| 完全不同的树 | O(n³) | O(n) | React 直接重建 |
| 列表顺序变化 | O(n²) | O(n) | key 优化 |
| 列表插入/删除 | O(n²) | O(n) | key 优化 |
四、批量更新与异步渲染
4.1 批量更新的原理
React 将多次 setState 合并为一次更新:
class Counter extends React.Component { state = { count: 0 };
handleClick = () => { // 这三次 setState 会合并 this.setState({ count: this.state.count + 1 }); this.setState({ count: this.state.count + 1 }); this.setState({ count: this.state.count + 1 });
// 此时 state.count 仍然是 0! console.log(this.state.count); // 0
// 在 React 18 的 automatic batching 中 // 即使在 setTimeout/Promise 中也会批量更新 };}4.2 React 18 的自动批量更新
4.3 并发渲染(Concurrent Rendering)
React 18 引入的并发渲染允许 React 中断渲染:
// 使用 useTransition 标记低优先级更新function SearchResults() { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition();
const handleChange = e => { // 高优先级:立即更新输入框 setQuery(e.target.value);
// 低优先级:可以延迟的搜索结果更新 startTransition(() => { setResults(search(e.target.value)); }); };
return ( <div> <input value={query} onChange={handleChange} /> {isPending && <Spinner />} <Results data={results} /> </div> );}4.4 更新优先级系统
五、虚拟 DOM vs 直接 DOM 操作性能对比
5.1 性能对比实验
一个实际性能测试的结果:
| 操作类型 | innerHTML | DocumentFragment | 虚拟 DOM(首次) | 虚拟 DOM(更新) |
|---|---|---|---|---|
| 首次渲染 10000 项 | 45ms | 52ms | 120ms | - |
| 更新其中 100 项 | 50ms | 55ms | 35ms | 35ms |
| 更新其中 1 项 | 48ms | 50ms | 2ms | 2ms |
| 无变化重新渲染 | 45ms | 50ms | 5ms | 5ms |
5.2 为什么首次渲染较慢?
首次渲染时,虚拟 DOM 需要额外的创建和遍历开销。但这些开销换来的是:
- 更新时的性能优势:精确计算最小变更集
- 开发效率:声明式编程模型
- 一致性保证:可预测的更新行为
5.3 虚拟 DOM 更适合的场景
5.4 虚拟 DOM 的性能边界
// 性能敏感场景:虚拟 DOM 的开销可能成为瓶颈function LargeList({ items }) { // 每帧都创建大量虚拟 DOM 节点 return ( <div> {items.map((item, i) => ( // 10000+ 个组件实例 <ComplexItem key={i} data={item} /> ))} </div> );}
// 优化方案:使用虚拟列表import { FixedSizeList } from "react-window";
function OptimizedList({ items }) { return ( <FixedSizeList height={600} itemCount={items.length} itemSize={50}> {({ index, style }) => ( <div style={style}> <ComplexItem data={items[index]} /> </div> )} </FixedSizeList> );}六、虚拟 DOM vs Svelte 编译时优化
6.1 两种优化思路
6.2 Svelte 的编译策略
Svelte 在编译时分析代码,生成直接的 DOM 更新指令:
<!-- Svelte 源代码 --><script> let count = 0; function increment() { count += 1; }</script>
<button on:click={increment}> Count: {count}</button>编译后生成的更新代码(简化版):
// Svelte 生成的更新函数function update(changed, ctx) { if (changed.count) { text_node.data = "Count: " + ctx.count; }}6.3 性能对比
| 维度 | React(虚拟 DOM) | Svelte(编译时) | 说明 |
|---|---|---|---|
| 首次渲染 | 较慢 | 快 | 无运行时开销 |
| 更新性能 | 好 | 更好 | 精确更新 |
| 运行时大小 | 较大(~40KB) | 极小(按需) | Svelte 无框架运行时 |
| 开发体验 | 成熟生态 | 快速迭代中 | React 生态更完善 |
| 调试能力 | DevTools 完善 | 相对较弱 | React 工具链成熟 |
| 跨平台 | React Native | Svelte Native | React 跨平台更成熟 |
6.4 设计权衡
React 团队的选择理由:
- 编译时优化的局限:无法处理动态场景(如根据数据渲染不同组件)
- 跨平台需求:React Native 需要统一的抽象层
- 生态一致性:运行时抽象保证了不同环境的兼容性
- 渐进增强:可以在运行时优化之上叠加编译时优化
七、Fiber 架构的演进
7.1 React 15 的问题:栈调和器
React 15 使用递归遍历虚拟 DOM 树:
// React 15 的递归更新(简化)function reconcile(parent, oldNode, newNode) { // 递归遍历,无法中断 if (oldNode.type !== newNode.type) { // 创建新节点... }
// 递归处理子节点 for (let i = 0; i < newNode.children.length; i++) { reconcile(parent, oldNode.children[i], newNode.children[i]); }
// 整个过程是同步的}问题:一旦开始更新,必须完成整个树的遍历。大应用可能造成明显的卡顿。
7.2 Fiber 的核心设计
React 16 重写了核心算法,引入 Fiber 架构:
// Fiber 节点结构(简化)const fiber = { type: "div", // 元素类型 key: null, // 标识 props: { className: "container" }, // 属性
// 树结构 return: parentFiber, // 父节点 child: firstChildFiber, // 第一个子节点 sibling: nextSiblingFiber, // 下一个兄弟
// 状态 alternate: oldFiber, // 上一次的 Fiber effectTag: "UPDATE", // 副作用标记
// 优先级 lanes: priority, // 更新优先级};7.3 可中断渲染
Fiber 架构实现了可中断的渲染:
7.4 双缓冲技术
React 使用两棵 Fiber 树实现双缓冲:
双缓冲的优势:
- 原子性更新:要么全部成功,要么全部回滚
- 中断恢复:可以随时暂停和继续
- 一致性视图:用户始终看到完整的 UI
7.5 Fiber 架构的性能收益
| 特性 | React 15 | React 16+ Fiber | 收益 |
|---|---|---|---|
| 渲染可中断 | 否 | 是 | 响应更快 |
| 优先级调度 | 无 | 有 | 重要更新优先 |
| 暂停/恢复 | 否 | 是 | 更好的用户体验 |
| 错误边界 | 局部 | 完善 | 更好的错误处理 |
| 大组件树卡顿 | 明显 | 可控 | 渐进式渲染 |
八、跨平台能力(React Native)
8.1 虚拟 DOM 作为跨平台抽象
虚拟 DOM 的真正威力在于它提供了一个与平台无关的 UI 抽象层:
8.2 React Native 的实现原理
// 同一套 React 代码function App() { return ( <View style={styles.container}> <Text>Hello, World!</Text> <Button title="Click me" onPress={handlePress} /> </View> );}
// Web 平台:渲染为 <div>, <span>, <button>// iOS 平台:渲染为 UIView, UILabel, UIButton// Android 平台:渲染为 ViewGroup, TextView, Button8.3 跨平台的代价与收益
| 方面 | 收益 | 代价 |
|---|---|---|
| 开发效率 | 一套代码多平台运行 | 需要学习平台特定知识 |
| 代码复用 | 业务逻辑完全复用 | UI 层可能需要平台适配 |
| 性能 | 接近原生 | Bridge 通信有开销 |
| 更新速度 | 热更新(无需应用商店审核) | 需要兼容性处理 |
| 生态 | 共享 React 生态 | 原生库需要封装 |
8.4 为什么原生框架难以取代 React Native?
九、声明式编程范式的优势
9.1 命令式 vs 声明式
9.2 代码复杂度对比
实现一个简单的条件渲染:
// 命令式(jQuery)function updateUI(user) { if (user.isLoggedIn) { $("#login-form").hide(); $("#user-profile").show(); $("#user-name").text(user.name); if (user.isAdmin) { $("#admin-panel").show(); } else { $("#admin-panel").hide(); } } else { $("#login-form").show(); $("#user-profile").hide(); $("#admin-panel").hide(); }}// 声明式(React)function UserProfile({ user }) { if (!user.isLoggedIn) { return <LoginForm />; }
return ( <div> <Profile name={user.name} /> {user.isAdmin && <AdminPanel />} </div> );}9.3 可预测性和可测试性
// React 组件测试:纯函数般简单test("UserProfile shows admin panel for admin user", () => { const user = { isLoggedIn: true, name: "Admin", isAdmin: true };
render(<UserProfile user={user} />);
expect(screen.getByText("Admin")).toBeInTheDocument(); expect( screen.getByRole("region", { name: "Admin Panel" }) ).toBeInTheDocument();});9.4 心智模型的简化
| 方面 | 命令式编程 | 声明式编程 |
|---|---|---|
| 状态管理 | 分散在多处 | 集中在 state/props |
| UI 更新 | 手动触发 | 自动响应状态变化 |
| 代码阅读 | 需要理解执行流程 | 直接看渲染函数 |
| Bug 排查 | 难以定位状态不一致 | 状态与 UI 对应关系清晰 |
| 重构 | 容易引入 Bug | 修改渲染函数即可 |
| 团队协作 | 需要理解全部代码 | 组件边界清晰 |
十、总结与权衡
10.1 虚拟 DOM 的核心价值
10.2 虚拟 DOM 的代价
| 代价类型 | 具体表现 | 应对策略 |
|---|---|---|
| 内存开销 | 维护虚拟 DOM 树 | 现代设备内存充足 |
| CPU 开销 | Diff 计算成本 | 算法优化、并发渲染 |
| 首次渲染性能 | 比直接 DOM 操作慢 | 服务端渲染、预编译 |
| 学习曲线 | 需要理解新范式 | 文档完善、社区支持 |
| 调试复杂性 | 状态追踪有时困难 | DevTools 支持 |
10.3 适用场景判断
10.4 虚拟 DOM 不是银弹
虚拟 DOM 是一种权衡,而不是银弹:
- 不是最快:编译时优化(如 Svelte)在特定场景更快
- 不是最小:运行时增加了包体积
- 不是万能:对极致性能场景需要额外优化
- 但它是务实的:在大多数场景提供了良好的开发体验和可接受的性能
10.5 React 团队的核心洞察
React 团队在设计虚拟 DOM 时的核心洞察:
开发者时间比 CPU 时间更宝贵。
虚拟 DOM 的价值不在于它比原生 DOM 操作更快,而在于:
- 提供一致的编程模型:开发者不需要关心 DOM 操作的细节
- 避免常见错误:批量更新、避免强制同步布局等
- 实现跨平台:一套代码,多端运行
- 支撑生态发展:稳定的 API 促进工具链和组件库发展
参考资料
- React 官方文档 — React 官方学习资源
- React Fiber Architecture — Fiber 架构设计说明
- React Conf 2017: Fiber — Fiber 架构介绍演讲
- Reconciliation — React Docs — React 协调机制文档
- React 18: Concurrent Features — React 18 并发特性介绍
- RFC: React Flare — React RFC 提案讨论
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






