REACT · Vol.I · LESSON 05 · 核心原语
渲染模型:虚拟 DOM 与协调 diff
先说结论:真实 DOM 操作很「贵」
浏览器里的「真实 DOM」是一个庞大而复杂的对象。随便一个 <div> 节点,背后都挂着一堆属性和方法,而且它牵扯到浏览器的布局(layout)和绘制(paint)——改一次 DOM,可能触发整棵树的回流。
如果每次状态变化都直接改真实 DOM,就会像这样低效:
for (let i = 0; i < 100; i++) {
document.getElementById("item" + i).textContent = newData[i];
// 每一次赋值都可能触发一次 reflow/repaint
}
任务其实只是「更新 100 个列表项」,却可能引发上百次昂贵的布局计算。虚拟 DOM 就是为了「少碰真实 DOM」而生的。
虚拟 DOM:一份轻量的「界面快照」
虚拟 DOM 本质上就是一个普通的 JavaScript 对象,用来描述「界面长什么样」。它不渲染任何东西,只是一份内存里的数据结构,所以创建和比较都极快:
// <h1 className="title">Hello</h1> 大概长这样:
{
type: "h1",
props: {
className: "title",
children: "Hello"
}
}
React 的工作流程是这样的(三阶段):
- 渲染(Render):状态变了 → React 重新调用你的组件函数,得到一份新的虚拟 DOM 树;
- 协调(Reconcile)/ diff:拿新树和旧树对比,找出「哪些地方真的变了」;
- 提交(Commit):只把「变了的」部分同步到真实 DOM。
注意第 2 步——这就是俗称的 diff 算法。它的价值在于:在虚拟 DOM 里算完差异,再一次性、最小化地操作真实 DOM。
这很像数据库的「索引 + 只更新脏页」的思路:不是每条数据变更都立刻刷盘,而是先在内存里算清楚,再批量落盘。虚拟 DOM 就是在内存里「批量对比 + 最小化刷屏」。也可以类比 ORM 的脏检查:Hibernate 不会把整个对象图无脑 UPDATE 一遍,而是比对快照、只生成变更字段的 SQL——React 干的是同一件事,对象是 DOM。
diff 为什么快:三条「偷懒」规则
理论上,两棵树做精确对比的复杂度是 O(n³)(n 是节点数),对一棵几百节点的树来说太慢了。React 靠三条启发式假设把复杂度降到 O(n):
| 规则 | 含义 | 你该怎么做 |
|---|---|---|
| 同层比较 | 只比较同一层级的节点,不跨层 | 避免把节点在层级间搬来搬去 |
| 类型不同 → 重建 | <div> 换成 <span>,整个子树直接重建 | 别频繁切换节点类型 |
| key 标识稳定性 | 列表项靠 key 判断是「新增/删除/移动」 | 列表务必给稳定且唯一的 key |
这三条本质上是三个「赌注」:赌界面很少跨层搬节点、赌类型切换不常见、赌你能提供稳定的 key。赌对了,O(n) 的线性扫描足够快;赌错了(比如滥用 index 做 key),diff 照样会做大量无用功。第三条 key 尤其重要,第 7 篇会专门展开。先看一张对比图,建立直觉:
- 虚拟 DOM 是内存里的界面快照,创建/比较极快。
- 「渲染 → 协调(diff)→ 提交」三阶段,只在最后一步碰真实 DOM。
- diff 的三条规则(同层、类型重建、key 稳定)把复杂度从 O(n³) 降到 O(n)。
最重要的澄清:重渲染 ≠ 重写 DOM
把三阶段模型攥在手里之后,一个流传极广的误解必须当场拆掉:「组件重渲染」发生在第 1 阶段(重新执行组件函数、生成新虚拟 DOM),而真实 DOM 动没动、动了多少,取决于第 2、3 阶段的 diff 结果。两者经常不相等:
function Profile({ user }) {
console.log("我被重新执行了"); // 每次父组件更新都会打印
return <div>{user.name}</div>;
}
// 父组件因为别的 state 变化而更新,把 user 原样传下来:
// 1. Profile 被重新执行(重渲染)✅
// 2. diff 发现新旧 JSX 一模一样 ✅
// 3. 这一步对真实 DOM 零操作 ✅
所以「页面卡」的时候先别怪 DOM,先分清瓶颈在哪一段:
| 瓶颈阶段 | 症状 | 对策(后文篇目) |
|---|---|---|
| 渲染阶段:组件函数执行太频繁 / 太重 | 交互掉帧,明明界面没怎么变 | memo / useMemo、状态下移(第 11、37 篇) |
| 提交阶段:DOM 变更量真的很大 | 一次性插入上万节点 | 虚拟列表(第 38 篇) |
| 两者都不是 | 其实不卡 | 别做没有证据的优化 😄 |
顺便说句公道话:虚拟 DOM 不是「更快的 DOM」
还有一句流行口号需要修正:「虚拟 DOM 比直接操作 DOM 快」。不对——论单次更新的极限速度,手写精准 textContent = x 永远比「建树 → diff → 提交」三步快。虚拟 DOM 赢的不是极限速度,而是:
- 下限高:不需要你手动找最小更新集,闭着眼写也有不错的性能;
- 正确性兜底:更新路径集中收口,杜绝「忘了同步某处」的命令式经典 bug;
- 可移植:同一套描述能渲染到 DOM、原生移动端、甚至服务端——这是「字符串拼 HTML」永远做不到的。
用后端的话说:这就像 ORM 对比手写 SQL——极致场景下手写更快,但团队规模的工程里,「够快 + 不会错 + 好维护」的 ORM 才是默认选项。
那到底是谁触发了重渲染?
把「何时会重渲染」也一次讲清,只有四种来源:
- 自己的 state 变了——调用了
setState; - 父组件重渲染了——默认「父渲染,子跟着渲染」,无论 props 变没变;
- 消费的 Context 变了(卷二第 15 篇);
- 订阅的外部数据源变了(如第 18/19 篇的状态库)。
注意第 2 条:父组件更新会带着所有子组件一起重新执行——这是 React 的默认行为(宁可多算,不可算错)。别慌,重执行的只是函数调用,贵不贵还要看 diff;而「跳过不必要的子渲染」的优化手段(memo 等),第 11 篇和第 37 篇会系统讲。
为什么这跟你(后端)有关
本章回顾
很多后端同学把 React 当「魔法」,其实它的渲染模型非常工程化:用一层轻量对象隔离昂贵的 DOM,再通过 diff 最小化副作用。这和你熟悉的「缓存 + 只刷脏数据」「连接池复用」是同一类工程智慧——先理解它、再利用它,而不是敬畏它。
本篇再多背三句话:重渲染是「重新执行函数」,不是「重写页面」;虚拟 DOM 赢在下限和正确性,不在极限速度;性能问题先定位在渲染阶段还是提交阶段。
学完这篇,你已经能理解 React 性能问题的根源了:组件重渲染 ≠ 真实 DOM 更新。真正的性能瓶颈往往在「渲染阶段算得太多」,后面第 6 卷会深入。下一篇先回到日常开发,看看用户交互的入口——事件处理。
Comments · 评论