Chapter 02

渲染机制:触发、渲染、提交

上一章把 UI 看成 state 的纯函数 f。这一章追问:当 state 变了,React 究竟做了什么才让真实 DOM 跟上——以及为什么“重渲染”远没有名字听起来那么贵。

本章你将建立的 schema

  • 一次更新分三步:触发(trigger)→ 渲染(render,调用组件)→ 提交(commit,改 DOM)
  • 协调(reconciliation):React 如何 diff 两棵元素树,为什么是 O(n) 而不是 O(n³)
  • key 的真正职责:声明列表项的“身份”,决定状态在重排时跟谁走

2.1渲染的三步:触发 → 渲染 → 提交

一次更新分三步:触发(state 变了)、渲染(React 调用你的组件得到新描述)、提交(React 把差异改到真实 DOM)。

为什么需要它

“渲染”这个词被严重滥用,把“调用组件函数”和“改 DOM”混为一谈,于是新手以为每次渲染都在重写页面、因此很慢。把它拆成三步,你才能准确说出某次交互发生了什么、哪一步碰了 DOM、哪一步没碰。

底层机制 · 比文档深一层

触发:只有两种来源——首次渲染(createRoot(...).render()),或某个组件 setState。
渲染(render 阶段):React 调用组件函数,递归调用子组件,得到一棵新的元素树(§1.3 的描述)。这一步是纯计算,不碰 DOM,因此可以被打断、丢弃、重做——并发渲染正是建立在这一点上。
提交(commit 阶段):React 把新旧树的 diff 结果应用到真实 DOM。这一步同步、不可打断、确实改 DOM。提交完,浏览器才重新绘制(paint)。

最关键的一处误解

“重渲染”默认指 render 阶段——重新调用函数算出新描述——不等于“重写 DOM”。大量重渲染在 commit 阶段几乎不动 DOM,因为 diff 后没有差异。把“组件被重新调用”和“真实 DOM 被改”分开,是读懂 React 性能的第一步。

① 触发 state 变 / 首次 ② 渲染 Render 调用组件 → 新树 纯计算 · 不碰 DOM ③ 提交 Commit diff → 改 DOM 同步 · 真正改 DOM 浏览器绘制
图 2.1一次更新的三步。注意:只有第 ③ 步 commit 碰真实 DOM;第 ② 步 render 只是重新调用你的函数、算出一份新描述,可以被丢弃重来。把“慢”归咎于“重渲染”之前,先分清它卡在哪一步。
想一想

父组件 setState 重渲染时,一个 props 完全没变的子组件,会不会被重新调用?真实 DOM 会变吗?

展开答案(先停 10 秒)

默认情况下,子组件会被重新调用(render 阶段)——React 默认在父组件重渲染时递归重渲染所有子组件,不预先检查 props 变没变。但因为它返回的描述和上一次相同,diff 后没有差异,commit 阶段不动那部分真实 DOM。“被重新调用”≠“DOM 被改”。如何让没变的子组件连函数都跳过(手动 memo 或 React Compiler 自动处理),是第 5 章的话题。

2.2协调:怎么 diff 两棵树

协调(reconciliation)是 React 对比新旧两棵元素树、算出最小 DOM 操作的过程。

为什么需要它

通用的“最小编辑距离”树 diff 是 O(n³),对动辄上千节点的 UI 完全不可用。React 用两条启发式假设,把它压到 O(n)——代价是这两条假设偶尔会“误判”,而理解这两条假设,正是预测 React 何时保留、何时丢弃组件状态的钥匙。

底层机制 · 两条假设

假设一:不同类型的元素,产出不同的树。同一位置上 <div> 变成了 <span>,React 不去 diff 内部,直接销毁整棵旧子树(连同其中所有组件的 state)、重建新子树。
假设二:同一位置、同一类型的元素,是“同一个”。React 复用那个真实 DOM 节点,只更新变化的属性,再递归 diff 它的子节点。
关键词是“位置”:React 靠“元素在树中的位置 + 类型”来判断“这是不是上次那个组件”。位置和类型都没变 → 复用并保留 state;类型变了,或位置变了 → 重建并丢掉 state。

旧树 div p · "Hi" input 新树 div p · "Yo" input div=div:复用 只改文字 Hi→Yo input 同位置同类型 → 复用同一 DOM,连输入内容一起保留 若 input 变成 textarea(类型变)→ 销毁重建,输入内容丢失
图 2.2协调按“位置 + 类型”逐点对比。注意:那个 input 因为位置和类型都没变而被复用,它内部未提交的输入内容会原样保留下来。这正是下一节 key、以及第 3 章“状态保留与重置”的根:状态是绑在“树中位置”上的,不是绑在数据上的。

2.3key:列表项的身份证

key 告诉 React“列表里这一项是谁”,让它在增删、重排时把每项的 DOM 和 state 跟对人。

为什么需要它

列表项没有天然的“位置稳定性”:插入、删除、排序会让“第 i 个”指向不同的数据。上一节说过,React 默认按位置匹配——对会变动的列表,这恰好是错的。key 给每项一个稳定身份,让匹配从“按位置”切换到“按身份”。

list-key.jsx JSX
// ❌ 用数组下标当 key:列表一重排,state 就跟错行
{todos.map((todo, i) => (
  <TodoRow key={i} todo={todo} />
))}

// ✅ 用数据自带的稳定 id 当 key:state 永远跟着数据走
{todos.map((todo) => (
  <TodoRow key={todo.id} todo={todo} />
))}
底层机制 · 比文档深一层

有了稳定 key,React 改用 key 而非位置来匹配新旧项:key 相同 → 判定为同一项,复用其 DOM/state 并移动到新位置;key 消失 → 删除该项;出现新 key → 新建。用下标 i 当 key,等于又退回“按位置匹配”——重排时数据的位置变了、下标也跟着变,于是 key 和位置一起漂移,匹配错乱。下标当 key 不是性能问题,是正确性问题:它会让一项的 DOM 内部状态(如未提交的输入文字)串到另一项上。

旧列表 key=0 · Ann key=1 · Bob 输入草稿:X key=2 · Cay 新列表 key=0 · Ann key=1 · Cay 位置0 复用 位置1 复用 位置2 整体删除 Cay 继承了 Bob 的旧节点,连草稿 X 一起
图 2.3用下标当 key 删除中间项的后果。注意:React 以为“位置 1 还是位置 1”,于是把 Bob 的旧 DOM 节点(连同输入框里的草稿 X)复用给了 Cay。改用 key={todo.id},React 会发现 Bob 这个 key 消失了,删掉 Bob 的节点,Ann 与 Cay 各自的状态原样不动。

2.4把三步与协调串起来

一次点击“删除 Bob”发生了什么,用本章三个概念走一遍:① 触发——删除按钮的回调 setTodos 产生了新数组(§1.4 的事件向上 + 即将在第 3 章讲的新数组)。② 渲染——React 重新调用列表组件(§1.2 的 f),得到一棵少了 Bob 的新元素树。③ 协调 + 提交——React 按 key 把新旧两棵树匹配:稳定 key 下它精准删除 Bob 的节点,commit 阶段只对真实 DOM 做一次删除操作。整页其余部分的真实 DOM 一动不动——这就是“重渲染整个列表”却依然便宜的原因。

§本章 self-check

先合上教程,把答案写下来,再展开对照。

  1. 把“渲染”拆成三步,分别说出哪一步碰真实 DOM、哪一步不碰。
  2. “组件被重新渲染”和“它对应的真实 DOM 被修改”是同一件事吗?用一句话说清两者的关系。
  3. React 的协调为什么能做到 O(n)?它靠的两条假设各是什么?
  4. (设计题)一个带输入框的列表,用数组下标当 key,删除中间一项后,某个输入框的草稿“串”到了别的行。从“按位置匹配”出发解释为什么,并说出正确做法。
答案(先做完再展开)
  1. 触发(state 变或首次,不碰 DOM)→ 渲染(调用组件算新树,纯计算,不碰 DOM)→ 提交(把 diff 应用到 DOM,碰 DOM)。只有提交碰真实 DOM。
  2. 不是同一件事。重渲染 = 重新调用组件函数得到新描述;只有当新描述与旧描述 diff 出差异时,提交阶段才会改对应的真实 DOM。重渲染可以完全不改 DOM。
  3. 靠两条启发式假设把通用 O(n³) 树 diff 降到 O(n):① 类型不同则整棵子树重建,不深入比较;② 同位置同类型则复用、只更新变化属性并递归。列表再用 key 提示稳定身份。
  4. 下标当 key 等于让 React 继续“按位置匹配”:删除中间项后,后面的数据全部前移一个位置,但下标 key 不变,于是 React 认为“位置 i 还是同一项”,把旧 DOM 节点(含未提交的输入草稿)复用给了新数据。正确做法是用数据自带的稳定 id 当 key,让匹配按身份而非位置进行。
进阶挑战 · 刚好够不着

条件渲染会不会重置输入框?

有两段条件渲染:
写法 A:{isEditing ? <input /> : <input disabled />}
写法 B:{isEditing ? <input /> : <p>...</p>},并在另一处单独再放一个 <input />。
切换 isEditing 时,哪种写法会让用户在 input 里打的字消失?用 §2.2 的“位置 + 类型”规则推理。

提示(卡住再展开)

问自己:切换前后,那个 input 在树中的“位置 + 类型”变了没有?写法 A 切换前后都是“同一位置上的 input”——类型没变,React 复用同一个 DOM,文字保留。写法 B 里,input 与 p 在同一位置上互相替换——类型从 input 变成 p(或反之),触发假设一:销毁重建,文字丢失。要刻意保留或刻意重置状态,关键就是控制元素在树中的位置与类型,或用 key 强制区分。第 3 章会把这条规则用到底。