知

模块一 · 起步与渲染

第 5 / 18 节17 分钟阅读型

一次渲染到底发生了什么:render 与 commit

setState 之后并不是立刻改 DOM。这一节拆开 render 与 commit 两个阶段,解释双缓冲、StrictMode 双调用与副作用时机。

读完这一节,你能做到

  1. 说出触发一次渲染的三种方式。
  2. 区分 render 阶段与 commit 阶段各自做什么。
  3. 用双缓冲解释"渲染中断后为什么界面还是旧的"。
  4. 判断一个副作用该放在 render、effect 还是事件里。

一、触发渲染的三种方式

方式例子特点
首次挂载createRoot().render()必然发生一次
状态更新setState、useReducer最常见
父组件重渲染父组件渲染,子组件默认跟着渲染可用 memo 阻断

注意:props 变化本身不会触发渲染,是父组件渲染时"顺带"让子组件也渲染。

二、render 阶段:算出新树

render 阶段做的事:

执行组件函数 → 生成新的 React 元素树 → 与旧树对比 → 标记需要变更的节点

三个特征:

  • 可中断:并发模式下,render 可以被更高优先级的更新打断,稍后重放。
  • 纯计算:不应该有副作用,因为可能执行多次、被丢弃。
  • 不等于 DOM 更新:这一步只产出"变更标记",还没碰真实 DOM。

三、commit 阶段:真正改 DOM

commit 阶段是同步的,分三步:

步骤做什么典型 API
before mutation读取 DOM 快照getSnapshotBeforeUpdate
mutation插入/更新/删除 DOM真实 DOM 操作
layout布局副作用useLayoutEffect

之后浏览器绘制,再执行 useEffect(被动副作用)。所以"读取布局"要用 useLayoutEffect,"发请求/订阅"用 useEffect。

四、双缓冲:两棵 Fiber 树

React 在内存里维护两棵树:

current 树:当前屏幕上显示的
workInProgress 树:正在构建的

render 阶段在 workInProgress 上构建,commit 完成后两者互换。这样即使 render 被打断或丢弃,屏幕上的 current 树仍然完整。

这解释了一个常见疑问:为什么在 render 里改了 ref,界面却没变?因为 render 可能还没提交,ref 的修改属于"副作用",不应该放在 render 里。

五、StrictMode 为什么调用两次

开发环境下,StrictMode 会故意:

  • 双调用组件函数,暴露不纯的渲染。
  • 双调用 effect 的 mount/unmount,暴露没有清理的副作用。

生产环境不会双调用。所以"只在开发环境出现两次请求"通常不是 bug,而是提醒你把副作用写纯、把清理补上。

六、副作用该放哪

需求位置原因
根据 props 计算值render 或 useMemo纯计算
发请求、订阅useEffect与渲染解耦
读取布局、测量useLayoutEffect绘制前执行
用户点击后做事事件回调明确的触发点

判断标准:这段逻辑是否由"渲染"引起?是,放 effect;否,放事件。

七、常见坑

  1. 在 render 里发请求,StrictMode 下请求两次。
  2. 用 useEffect 读布局,导致闪烁。
  3. 以为 setState 后 DOM 立刻更新,紧接着去读还是旧值。
  4. 忘记清理订阅,切换页面后仍在更新状态。
  5. 把"渲染次数多"当成 bug,实际上是父组件正常重渲染。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 在组件里同时打 render、useLayoutEffect、useEffect 的日志,观察顺序。
  2. 用 StrictMode 包住一个带请求的组件,观察请求次数。
  3. 写一个测量元素高度的组件,分别用 useEffect 与 useLayoutEffect 对比闪烁。

② 让 AI 写同一题

把第 3 题(测元素高度)交给 AI,补一句 只能用 Hooks,不引入测量库。

③ 对比要盯的点

  • 它默认选了 useEffect 还是 useLayoutEffect?选错就会先画出旧高度再跳一下。
  • 它有没有在渲染阶段直接读 ref.current.offsetHeight?首次渲染时那里还是 null。
  • ResizeObserver 它有 disconnect 清理吗?

④ 一句话取舍

它为什么选 useEffect?给出的理由成立,还是只是因为“大家都这么写”?

九、自测

  1. 触发渲染的三种方式是什么?
  2. render 与 commit 的分工是什么?
  3. 双缓冲解决了什么问题?
  4. StrictMode 双调用的目的是什么?
  5. 什么时候用 useLayoutEffect?

参考答案要点:挂载/状态/父渲染;算树 vs 改 DOM;中断时保持屏幕完整;暴露不纯与未清理;绘制前读取布局。

附:render 阶段的三个常见副作用

副作用后果正确位置
发请求StrictMode 下重复请求useEffect
改 ref可能被中断丢弃useEffect / 事件
读 DOM此时 DOM 还是旧的useLayoutEffect

记住一句话:render 只负责“算”,不负责“做”。

附:commit 之后发生了什么

commit 完成 → 浏览器绘制 → 执行 useEffect。所以:

  • useEffect 里读到的 DOM 已经是新的。
  • 在 useEffect 里同步改样式,用户可能看到一帧旧界面,这叫闪烁。
  • 需要“绘制前”完成的事情,放 useLayoutEffect。

附:一次更新的时间线

点击 → setState → 调度 → render(可中断)
→ commit(同步)→ 绘制 → useEffect

如果中途有更高优先级的更新,render 会被打断重来,commit 不会。理解这条时间线,就能解释“为什么 setState 后立刻读 DOM 是旧值”“为什么 effect 里能读到新 DOM”。

附:为什么 commit 不能中断

commit 会直接修改真实 DOM。如果执行到一半被打断,屏幕会出现“一半新、一半旧”的界面,用户会看到闪烁或错位。所以 React 把 commit 设计成同步且不可中断,把“可中断”放在前面的 render 阶段。

代价是:如果一次 commit 里有大量 DOM 变更,主线程仍会被阻塞。解决方向不是“中断 commit”,而是减少一次 commit 的工作量(虚拟列表、分批更新)。

附:effect 的执行顺序与清理

同一组件内,多个 effect 按声明顺序执行;子组件的 effect 先于父组件执行。清理函数的执行顺序则相反:先清理父组件,再清理子组件。

useEffect(() => {
  /* A */ return () => {
    /* A cleanup */
  };
}, []);
useEffect(() => {
  /* B */ return () => {
    /* B cleanup */
  };
}, []);

理解顺序,才能解释“为什么父组件的订阅先于子组件取消”这类问题。

附:为什么 effect 里读到的 DOM 是新的

因为 effect 在 commit 之后执行,此时 DOM 已完成变更。相反,render 阶段读到的 DOM 还是旧值,所以不要在 render 里测量元素尺寸。

附:一次点击的完整链路

点击 → 事件回调 → setState → 调度
→ render(算新树)→ commit(改 DOM)
→ 绘制 → useEffect(同步外部系统)

这条链路能解释三件事:为什么 setState 后读 state 是旧值、为什么 effect 里 DOM 已更新、为什么 useLayoutEffect 能避免闪烁。记住这条线,它是所有调试的起点。

附录:高频追问

问:为什么 render 可能执行多次? 并发模式可中断重放、StrictMode 开发期双调用、状态更新后重算。

问:commit 为什么不能中断? 直接改 DOM,中断会出现半成品界面。

问:useEffect 与 useLayoutEffect 怎么选? 读布局用 layout,请求/订阅用 effect。

问:为什么 effect 里 setState 会多一次渲染? effect 在绘制后执行,setState 会再触发一轮更新。

问:StrictMode 会双调用生产环境吗? 不会,只在开发环境。

问:setState 后 DOM 何时更新? 在 commit 阶段,通常在本轮事件结束后、浏览器绘制前。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解