首页 / React 学习笔记 / 05

REACT · Vol.I · LESSON 05 · 核心原语

渲染模型:虚拟 DOM 与协调 diff

进阶核心#核心#原理

先说结论:真实 DOM 操作很「贵」

浏览器里的「真实 DOM」是一个庞大而复杂的对象。随便一个 <div> 节点,背后都挂着一堆属性和方法,而且它牵扯到浏览器的布局(layout)和绘制(paint)——改一次 DOM,可能触发整棵树的回流。

如果每次状态变化都直接改真实 DOM,就会像这样低效:

命令式更新:改 100 次,回流 100 次
for (let i = 0; i < 100; i++) {
  document.getElementById("item" + i).textContent = newData[i];
  // 每一次赋值都可能触发一次 reflow/repaint
}

任务其实只是「更新 100 个列表项」,却可能引发上百次昂贵的布局计算。虚拟 DOM 就是为了「少碰真实 DOM」而生的。

虚拟 DOM:一份轻量的「界面快照」

虚拟 DOM 本质上就是一个普通的 JavaScript 对象,用来描述「界面长什么样」。它不渲染任何东西,只是一份内存里的数据结构,所以创建和比较都极快:

JSX 编译出来的「虚拟 DOM」对象
// <h1 className="title">Hello</h1> 大概长这样:
{
  type: "h1",
  props: {
    className: "title",
    children: "Hello"
  }
}

React 的工作流程是这样的(三阶段):

  1. 渲染(Render):状态变了 → React 重新调用你的组件函数,得到一份新的虚拟 DOM 树;
  2. 协调(Reconcile)/ diff:拿新树和旧树对比,找出「哪些地方真的变了」;
  3. 提交(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 篇会专门展开。先看一张对比图,建立直觉:

无 key(按下标比对) 更新前 A B C 头部插入 X 更新后 X A B C 逐位比对:A→X、B→A、C→B「都变了」 → 四项全部重写,白白干活 稳定 key(按身份比对) 更新前 A B C key="a" key="b" key="c" 头部插入 X 更新后 X A B C key 对上号:a/b/c 都还在,只新增 x → 只做一次插入,A/B/C 原样复用
图 1同样的「头部插入 X」:没有稳定 key 时,React 逐位比对会误判每一项都变了;有了 key,它能精准识别「只有 X 是新增」。key 的完整机制见第 7 篇。
核心要点
  • 虚拟 DOM 是内存里的界面快照,创建/比较极快。
  • 「渲染 → 协调(diff)→ 提交」三阶段,只在最后一步碰真实 DOM。
  • diff 的三条规则(同层、类型重建、key 稳定)把复杂度从 O(n³) 降到 O(n)。

最重要的澄清:重渲染 ≠ 重写 DOM

把三阶段模型攥在手里之后,一个流传极广的误解必须当场拆掉:「组件重渲染」发生在第 1 阶段(重新执行组件函数、生成新虚拟 DOM),而真实 DOM 动没动、动了多少,取决于第 2、3 阶段的 diff 结果。两者经常不相等:

重渲染了,但 DOM 一点没变
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 才是默认选项。

那到底是谁触发了重渲染?

把「何时会重渲染」也一次讲清,只有四种来源:

  1. 自己的 state 变了——调用了 setState
  2. 父组件重渲染了——默认「父渲染,子跟着渲染」,无论 props 变没变;
  3. 消费的 Context 变了(卷二第 15 篇);
  4. 订阅的外部数据源变了(如第 18/19 篇的状态库)。

注意第 2 条:父组件更新会带着所有子组件一起重新执行——这是 React 的默认行为(宁可多算,不可算错)。别慌,重执行的只是函数调用,贵不贵还要看 diff;而「跳过不必要的子渲染」的优化手段(memo 等),第 11 篇和第 37 篇会系统讲。

为什么这跟你(后端)有关

本章回顾

很多后端同学把 React 当「魔法」,其实它的渲染模型非常工程化:用一层轻量对象隔离昂贵的 DOM,再通过 diff 最小化副作用。这和你熟悉的「缓存 + 只刷脏数据」「连接池复用」是同一类工程智慧——先理解它、再利用它,而不是敬畏它。

本篇再多背三句话:重渲染是「重新执行函数」,不是「重写页面」虚拟 DOM 赢在下限和正确性,不在极限速度性能问题先定位在渲染阶段还是提交阶段

学完这篇,你已经能理解 React 性能问题的根源了:组件重渲染 ≠ 真实 DOM 更新。真正的性能瓶颈往往在「渲染阶段算得太多」,后面第 6 卷会深入。下一篇先回到日常开发,看看用户交互的入口——事件处理。

Comments · 评论