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 性能的第一步。
父组件 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。
input 因为位置和类型都没变而被复用,它内部未提交的输入内容会原样保留下来。这正是下一节 key、以及第 3 章“状态保留与重置”的根:状态是绑在“树中位置”上的,不是绑在数据上的。2.3key:列表项的身份证
key 告诉 React“列表里这一项是谁”,让它在增删、重排时把每项的 DOM 和 state 跟对人。
列表项没有天然的“位置稳定性”:插入、删除、排序会让“第 i 个”指向不同的数据。上一节说过,React 默认按位置匹配——对会变动的列表,这恰好是错的。key 给每项一个稳定身份,让匹配从“按位置”切换到“按身份”。
// ❌ 用数组下标当 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={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
先合上教程,把答案写下来,再展开对照。
- 把“渲染”拆成三步,分别说出哪一步碰真实 DOM、哪一步不碰。
- “组件被重新渲染”和“它对应的真实 DOM 被修改”是同一件事吗?用一句话说清两者的关系。
- React 的协调为什么能做到 O(n)?它靠的两条假设各是什么?
- (设计题)一个带输入框的列表,用数组下标当 key,删除中间一项后,某个输入框的草稿“串”到了别的行。从“按位置匹配”出发解释为什么,并说出正确做法。
答案(先做完再展开)
- 触发(state 变或首次,不碰 DOM)→ 渲染(调用组件算新树,纯计算,不碰 DOM)→ 提交(把 diff 应用到 DOM,碰 DOM)。只有提交碰真实 DOM。
- 不是同一件事。重渲染 = 重新调用组件函数得到新描述;只有当新描述与旧描述 diff 出差异时,提交阶段才会改对应的真实 DOM。重渲染可以完全不改 DOM。
- 靠两条启发式假设把通用 O(n³) 树 diff 降到 O(n):① 类型不同则整棵子树重建,不深入比较;② 同位置同类型则复用、只更新变化属性并递归。列表再用 key 提示稳定身份。
- 下标当 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 章会把这条规则用到底。