6.1 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
2026-05-26 15:30 |
虚拟 DOM 和 真实 DOM 有什么区别
概述
虚拟 DOM(Virtual DOM)是 React 的"秘密武器"——它先用 JavaScript 对象在内存中画一张 UI 蓝图,然后通过一套高效的 Diff 算法找出改动最少的那部分,最后一次性刷到真实的页面上。这样既省去了频繁操作浏览器 DOM 的性能损耗,又保留了声明式编写 UI 的方便。
正文
先搞懂:什么是"真实 DOM"?
浏览器加载网页后,会把 HTML 文档转换成一棵节点树,这就是 DOM 树。每个 <div>、每段文字都是树上的一个节点。
<ul id="list">
<li>苹果</li>
<li>香蕉</li>
<li>橙子</li>
</ul>
对应的 DOM 树结构:
graph TD
UL["<ul id='list'>"] --> LI1["<li>苹果</li>"]
UL --> LI2["<li>香蕉</li>"]
UL --> LI3["<li>橙子</li>"]
问题来了:如果我想在"香蕉"前面加一个"草莓",传统写法是:
// ❌ 传统写法:手动改 DOM
const list = document.getElementById("list");
const strawberry = document.createElement("li");
strawberry.textContent = "草莓";
list.insertBefore(strawberry, list.children[1]);
看起来简单?但当页面有 100+ 个元素要同时更新时,这种逐条操作的方式会频繁触发浏览器的 重排(Reflow) 和 重绘(Repaint),体验明显卡顿。
[!QUESTION] 为什么多次修改 DOM 这么慢? 想象你在白板上画图:每擦一次就重新描一遍。而批量操作的思路是先画草稿纸,确认没问题了再一次性誊到白板上。
虚拟 DOM:DOM 树的"替身演员"
虚拟 DOM 不是新东西——它只是 用 JS 对象模拟 DOM 树的一份副本:
// JSX 写法(你写的)
const element = <ul id="list"><li>苹果</li><li>香蕉</li></ul>;
// React 内部实际生成的虚拟 DOM(纯 JS 对象)
const vdom = {
type: "ul",
props: { id: "list", children: [
{ type: "li", props: { children: ["苹果"] } },
{ type: "li", props: { children: ["香蕉"] } }
]}
};
这个对象 不占用任何浏览器资源,只在 JS 内存里跑。你可以把它理解为装修的 3D 效果图——你看的是图,但房子还没动。
| 真实 DOM | 虚拟 DOM | |
|---|---|---|
| 是什么 | 浏览器的 DOM 节点对象 | JS 普通对象 |
| 在哪运行 | 浏览器引擎(C++) | JS 引擎(V8) |
| 改动成本 | 高(触发布局计算) | 低(只是对象赋值) |
| 能跨平台吗 | ❌ 只能浏览器 | ✅ SSR、React Native |
核心原理:Diff + Patch
虚拟 DOM 的威力不在"虚拟"本身,而在它的 对比机制。流程只有三步:
flowchart LR
A["状态变化"] --> B["生成新的虚拟 DOM"]
B --> C["新旧对比(Diff)"]
C --> D["只更新变动的部分(Patch)"]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#fce4ec
style D fill:#e8f5e9
举个例子:添加一项水果
// 第 1 步:原始列表 —— 用户点了"添加草莓"
setState({ fruits: ["苹果", "香蕉", "橙子"] })
// ↓
// 🍎 🍌 🍊
// 第 2 步:React 生成新的虚拟 DOM
setState({ fruits: ["苹果", "香蕉", "草莓", "橙子"] })
// ↓
// 🍎 🍌 🍓 🍊
// 第 3 步:Diff 发现只有 1 处变化
// Patch 只需执行 1 次操作:<li>草莓</li> 插入到香蕉后面
对比两种方式的差异:
直接操作 DOM:
├── insertBefore() ← 触发重排
├── 可能需要调整后续索引 ← 额外逻辑
React 的 VDOM:
├── 创建新 JS 对象 ← 纯内存操作,极快
├── Diff 比较两棵树 ← 内存中运算,也不影响渲染
└── 批量应用变更 ← 浏览器只收到一条指令
[!TIP] CPU 换 GPU 思维 有人问:"Diff 本身也要算啊,不是多了一步吗?"——对,VDOM 确实多了一次 CPU 计算,但它把 N 次昂贵的 GPU(浏览器渲染管线的重排/重绘)合并成 1 次。用便宜的算力换取昂贵的渲染,这是现代前端的经典权衡。
Diff 是怎么做到这么快?
暴力对比两棵树的复杂度是 O(n³),不可接受。React 做了两个"偷懒"的设计把它们砍到 O(n):
- 同层对比:不同层的节点绝不拿来比,类型一变就直接销毁重建
- key 属性:列表项靠 key 来辨认身份,避免误判
假设列表从 [A, B, C] 变成 [B, A, C]:
暴力对比:逐个位置看,认为 3 个都变了 ❌
React Diff:通过 key 识别出 B 和 A 只是换了位置,只需交换 ✅
key 到底是什么?为什么非得加?
key 就是虚拟 DOM 对比时的 身份证。
// ✅ 正确:用不变的 ID 当 key
users.map(user => <li key={user.id}>{user.name}</li>)
// ❌ 错误:用 index 当 key
users.map((user, i) => <li key={i}>{user.name}</li>)
为什么不能用 index? 看这个场景:
初始列表:Alice(0), Bob(1), Carol(2)
删除 Alice 后:Bob 变成了 [0],Carol 变成了 [1]
用 index 当 key:
Diff 看到 index=0 的值从 "Alice" 变成了 "Bob"
→ 认为整个列表全变了,全部重建 ❌
用 user.id 当 key:
Diff 看到 id=1 的 "Bob" 只是位置上移了一位
→ 只做移动,不重建 ✅
[!NOTE] 什么时候可以用 index? 列表完全不会增删排序、且项之间没有任何状态交互——几乎不存在这样的场景。安全起见,永远用稳定唯一 ID。
完整工作流程
sequenceDiagram
participant U as 用户
participant R as React
participant V as 旧虚拟 DOM
participant New as 新虚拟 DOM
participant P as Diff 引擎
participant D as 真实 DOM
U->>R: 点击按钮 / setState
R->>New: 用最新数据生成新虚拟 DOM
R->>P: 传入旧虚拟 DOM 和新虚拟 DOM
P->>P: 逐层对比,记录最小变更集
P-->>R: 返回需要更新的位置
R->>D: 批量写入变更
D-->>U: 页面刷新