模块一 · 起步与渲染
一次渲染到底发生了什么:render 与 commit
setState 之后并不是立刻改 DOM。这一节拆开 render 与 commit 两个阶段,解释双缓冲、StrictMode 双调用与副作用时机。
读完这一节,你能做到
- 说出触发一次渲染的三种方式。
- 区分 render 阶段与 commit 阶段各自做什么。
- 用双缓冲解释"渲染中断后为什么界面还是旧的"。
- 判断一个副作用该放在 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;否,放事件。
七、常见坑
- 在 render 里发请求,StrictMode 下请求两次。
- 用
useEffect读布局,导致闪烁。 - 以为 setState 后 DOM 立刻更新,紧接着去读还是旧值。
- 忘记清理订阅,切换页面后仍在更新状态。
- 把"渲染次数多"当成 bug,实际上是父组件正常重渲染。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 在组件里同时打 render、useLayoutEffect、useEffect 的日志,观察顺序。
- 用 StrictMode 包住一个带请求的组件,观察请求次数。
- 写一个测量元素高度的组件,分别用 useEffect 与 useLayoutEffect 对比闪烁。
② 让 AI 写同一题
把第 3 题(测元素高度)交给 AI,补一句 只能用 Hooks,不引入测量库。
③ 对比要盯的点
- 它默认选了
useEffect还是useLayoutEffect?选错就会先画出旧高度再跳一下。 - 它有没有在渲染阶段直接读
ref.current.offsetHeight?首次渲染时那里还是null。 ResizeObserver它有disconnect清理吗?
④ 一句话取舍
它为什么选 useEffect?给出的理由成立,还是只是因为“大家都这么写”?
九、自测
- 触发渲染的三种方式是什么?
- render 与 commit 的分工是什么?
- 双缓冲解决了什么问题?
- StrictMode 双调用的目的是什么?
- 什么时候用 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 阶段,通常在本轮事件结束后、浏览器绘制前。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解