
一句话:React 的一次更新分「render 阶段(可中断、必须纯、算出变更)」与「commit 阶段(不可中断、真正改 DOM、执行 effect)」;理解这个划分就理解了「为什么副作用必须放 Effect 里」。
一、render 阶段(可中断)
做什么:
① 从 root 开始遍历 Fiber 树
② 对每个「有更新的」组件执行它的函数(函数组件)或 render(类组件)
③ 对每个节点做 diff(比较新旧子元素,标记变化)
④ 收集副作用标记(flags)到 effect 链上
⑤ 生成 workInProgress 树
特征:
✅ 可以被打断(时间切片)
✅ 可以被丢弃(有更高优先级任务时)
✅ 可以被重放(重新来过)
❌ 不能有副作用
❌ 不能操作 DOM(此时 DOM 还没改)
// 这个「render 阶段」就是组件函数体执行的过程
function Component({ name }: { name: string }) {
// ⬇️ 以下代码都在 render 阶段执行
const [count, setCount] = useState(0);
const upper = name.toUpperCase();
const sorted = useMemo(() => sort(data), [data]);
// useMemo / useCallback 的「计算函数」也在 render 阶段执行(当依赖变化时)
if (count > 10) return <Big />; // ⚠️ 注意:这不会导致 Hook 数量变化?会!见下面的说明
return <div>{upper}{sorted.length}</div>;
}
// ❌ render 阶段的常见副作用(都要避免)
function Bad() {
let renderCount = 0;
renderCount++; // 副作用:改外部(模块级)变量
fetch('/api'); // 副作用:请求
document.title = 'x'; // 副作用:改 DOM/浏览器状态
localStorage.setItem('a', 'b'); // 副作用:改存储
const id = Math.random(); // 不确定:每次渲染结果不同
return <div id={id} />;
}
二、commit 阶段(不可中断)
做什么:把 render 阶段算出的「副作用」一次性应用到真实 DOM
它分三个子阶段(按执行顺序):
① before mutation(DOM 变更前)
· 触发 getSnapshotBeforeUpdate(类组件)
· 调度 passive effects(把 useEffect 推入异步队列)
② mutation(真正改 DOM)
· 按 flags 执行:插入(Placement)、更新(Update)、删除(ChildDeletion)
· 执行 useLayoutEffect 的【清理函数】(对卸载/更新的组件)
· 卸载的节点上把 ref 置为 null
③ layout(DOM 变更后、浏览器绘制前)
· 赋值新的 ref(ref.current = dom)
· 执行 useLayoutEffect 的【effect 函数】
· 触发 componentDidMount / componentDidUpdate(类组件)
⚠️ 这个阶段【必须一次做完】,因为要让用户看到「一致的界面」。
// useLayoutEffect 就运行在 commit 的 layout 阶段(绘制前)
useLayoutEffect(() => {
// ✅ 此时 DOM 已更新,但还没绘制
const height = ref.current!.offsetHeight;
setHeight(height); // 会触发「同步重渲染」,在绘制前完成
return () => {
// 清理:在「下次 layout 阶段」或卸载时执行
};
}, []);
三、passive effects(useEffect)
位置:浏览器绘制【之后】(异步)
做什么:
① 执行上一次 useEffect 的【清理函数】
② 执行本次 useEffect 的【effect 函数】
为什么异步?
① 副作用通常不需要「阻塞绘制」(如请求、日志、订阅)
② 让用户尽快看到界面(首屏更快)
⚠️ 但它不会「等到浏览器空闲」才执行——
它在绘制之后的「宏/微任务」里尽快执行(具体调度由 React 决定)。
// useEffect 运行的阶段(绘制后)
useEffect(() => {
// ✅ 这里可以安全地做「不阻塞绘制」的副作用
const controller = new AbortController();
fetchData({ signal: controller.signal }).then(setData);
return () => controller.abort();
}, [id]);
四、完整时序图
dispatch / setState
│
▼
┌──────────────────────────────────────────────────┐
│ ① render 阶段(可中断 ⏸️) │
│ · 遍历 Fiber、执行组件函数、diff、收集 flags │
│ · 可能被丢弃 / 重放 │
└──────────────────────────────────────────────────┘
│ (计算完成)
▼
┌──────────────────────────────────────────────────┐
│ ② commit 阶段(不可中断 ▶️) │
│ a. before mutation │
│ b. mutation(改 DOM、执行 layout 清理) │
│ c. layout(ref 赋值、useLayoutEffect) │
└──────────────────────────────────────────────────┘
│
▼
③ 浏览器绘制(paint)
│
▼
┌──────────────────────────────────────────────────┐
│ ④ passive effects(异步) │
│ · useEffect 清理 → useEffect 执行 │
└──────────────────────────────────────────────────┘
// 用日志验证完整顺序(强烈建议自己跑一遍)
function Demo({ id }: { id: number }) {
console.log('① render', id);
useLayoutEffect(() => {
console.log('③ layout effect', id);
return () => console.log('③ cleanup layout', id);
});
useEffect(() => {
console.log('④ passive effect', id);
return () => console.log('④ cleanup passive', id);
});
return <div>{id}</div>;
}
// 首次挂载:
// ① render 1
// ③ layout effect 1
// ④ passive effect 1
// id 从 1 → 2:
// ① render 2
// ③ cleanup layout 1
// ③ layout effect 2
// ④ cleanup passive 1
// ④ passive effect 2
多个组件之间的顺序:
· 同一组件内:按【声明顺序】执行(先 useLayoutEffect 后 useEffect)
· 组件树:子组件的 effect【先于】父组件执行(因为 commit 是「由下往上」收集的)
· 清理函数:与声明/渲染顺序【相反】(后进先出)
⚠️ 记忆口诀:
render 由外到内(父 → 子),
清理由内到外(子 → 父),
effect 由内到外(子 → 父)。
五、各阶段能做什么 / 不能做什么
| 阶段 | 能读 DOM | 能改 DOM | 能做副作用 | 会阻塞绘制 |
|---|---|---|---|---|
| render(组件函数体) | ❌(DOM 还是旧的) | ❌ | ❌ | — |
| commit:mutation | 部分 | ✅(React 自己) | ❌ | ✅ |
commit:layout(useLayoutEffect) |
✅(已更新) | ✅(谨慎) | ⚠️ 同步副作用可以,但会阻塞 | ✅ |
passive(useEffect) |
✅ | ✅ | ✅ | ❌ |
| 事件处理函数 | ✅ | ✅ | ✅ | ❌ |
// 各阶段的正确用法
// ❌ render 阶段读 DOM(此时是旧 DOM)
function Bad() {
const width = ref.current?.offsetWidth; // ⚠️ 可能是上次的或 null
}
// ✅ layout 阶段读 DOM(DOM 已更新,绘制前)
useLayoutEffect(() => {
const width = ref.current!.offsetWidth;
setWidth(width); // 同步更新,避免闪烁
}, [deps]);
// ✅ passive 阶段做「不阻塞绘制」的副作用
useEffect(() => {
const ac = new AbortController();
fetch(url, { signal: ac.signal }).then(setData);
return () => ac.abort();
}, [url]);
// ✅ 事件处理函数里做「用户触发的」副作用
const handleClick = () => {
analytics.track('click');
};
六、为什么 render 必须纯
三个原因:
① 可能被中断 → 之后重放
render 阶段被打断后,React 会「重新执行」未完成的部分
→ 不纯的代码会执行多次(如重复请求、重复计数)
② 可能被丢弃
如果有更高优先级的更新插入,当前 render 的成果可能被整体丢弃
→ 已经执行的副作用「无法撤销」(如已经发出的请求)
③ 并发特性依赖它
React 的「可中断渲染」「Suspense」「useTransition」都建立在
「render 是纯函数」这个假设上
→ 所以 React 的规则是:
render 只做「计算」(根据 props/state 算出元素),
所有副作用放到 Effect 或事件处理里。
// StrictMode 会「双调用」来暴露不纯的 render(见 8.5)
let count = 0;
function Impure() {
count++; // ❌ StrictMode 下会变成 2
return <div>{count}</div>;
}
// ✅ 纯函数版本
function Pure({ items }: { items: Item[] }) {
const sorted = [...items].sort(); // 不改原数组,只计算
return <ul>{sorted.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}
七、常见坑
// ① 在 render 里读/写 DOM
function Bad() {
document.title = 'x'; // ❌ 应该在 useEffect 里
return <div />;
}
// ② 在 render 里发请求
function Bad2() {
useEffect(() => { fetch('/api'); }, []); // ✅
fetch('/api/other'); // ❌ 每次渲染都发
return <div />;
}
// ③ 认为 useEffect 在 render 之后「立刻」执行
// → 它在浏览器绘制之后(异步)
// ④ 用 useEffect 做「需要在绘制前完成」的事(导致闪烁)
useEffect(() => {
const h = ref.current!.offsetHeight;
setHeight(h); // ⚠️ 可能闪烁 → 用 useLayoutEffect
}, []);
// ⑤ 在 useLayoutEffect 里做「重计算」
useLayoutEffect(() => {
const result = heavyCompute(); // ❌ 阻塞绘制
}, []);
// ⑥ 在渲染中调用 setState 造成无限循环
function Bad3() {
const [n, setN] = useState(0);
setN(n + 1); // ❌ render → setState → render → ...
return <div>{n}</div>;
}
// ⑦ 认为「commit 阶段也会被打断」
// ⑧ 在 render 里用 Math.random() / Date.now()(不确定 → 可能 SSR mismatch)
// ⑨ 在 render 里修改 props/state 对象(破坏不可变假设)
// ⑩ 依赖「effect 的执行次数」来计数(可能因 StrictMode/重放而不准)
// ⑪ 在 effect 里读「渲染时的局部变量」而不是 ref(会受闭包影响)
// ⑫ 在 layout 阶段强制同步布局(读尺寸 → 改样式 → 再读,造成 layout thrashing)
八、面试延伸
- 「React 渲染分哪两个阶段?」
① render 阶段(也叫 reconcile/协调阶段)——遍历 Fiber 树、执行组件函数、diff 出变化、收集副作用标记;特点是可中断、可丢弃、可重放,因此必须纯粹(不能有副作用);② commit 阶段——把算出的变化一次性应用到真实 DOM,分为before mutation/mutation/layout三个子阶段,不可中断。之后是浏览器绘制,最后异步执行useEffect。 -
「
useEffect和useLayoutEffect分别在哪个阶段执行?」
useLayoutEffect在 commit 阶段的 layout 子阶段执行——此时 DOM 已更新但浏览器还没绘制,因此它会阻塞绘制,适合「读取布局并立即修正以防闪烁」;useEffect在浏览器绘制之后执行(异步),不阻塞绘制,适合「请求、订阅、日志」这类不需要立刻完成的工作。 -
「为什么 render 阶段必须是纯函数?」
因为它可能被中断后重放,也可能被整体丢弃。如果里面有副作用:① 会被执行多次(重复请求、重复计数);② 已执行的副作用无法撤销(影响用户);③ 会破坏 React「可中断渲染」「Suspense」「并发特性」的前提假设。所以规则是「render 只做计算,副作用放 Effect 或事件处理」。StrictMode 的双调用就是用来故意暴露不纯 render 的。 -
「为什么
useEffect要在绘制之后执行?」
因为大多数副作用(请求、日志、订阅)不需要阻塞绘制——让用户尽快看到界面更重要。把它放在绘制后异步执行,可以缩短「首屏可见时间」。反过来说,如果某个副作用的结果必须「在用户看到界面前就应用」(如测量尺寸后调整样式),它就不能放这里,要用useLayoutEffect。 -
「commit 阶段为什么不能中断?」
因为 commit 是「把变更真正写到 DOM 上」的过程。如果中途停下来,用户会看到改了一半的界面(比如列表删了一半、样式只应用了一部分)——这比「晚一点更新」糟糕得多。所以 React 的设计是「render 可以慢慢算(可中断),但一旦确定要提交,就必须一口气做完」。这也是为什么 commit 阶段里的代码绝不能慢。 -
「多个组件的 effect 执行顺序是怎样的?」
三条规律:① 同一组件内按声明顺序执行(useLayoutEffect先于useEffect,因为阶段不同);② effect 的执行顺序是「子 → 父」(React 在 commit 时由下往上收集 effect);③ 清理函数的顺序与「执行顺序」相反(后进先出)。记忆口诀是「render 由外到内,清理由内到外,effect 由内到外」。 -
「为什么说
useLayoutEffect会阻塞绘制?什么时候该用?」
因为它运行在「DOM 已更新、但浏览器还没绘制」的窗口里——浏览器必须等它(以及它触发的同步重渲染)完成后才能绘制。所以它会延长「首屏可见时间」。该用的场景是「读取 DOM 布局信息并据此立即修正 UI」:Tooltip 定位、自适应宽度、滚动位置恢复、自动聚焦。判断标准是「晚一帧修正用户会看到闪烁吗」。 -
「在 render 阶段读
ref.current会怎样?」
会读到上一次渲染的 DOM 或null(首次渲染时是null)。因为 render 阶段只是「计算」,真实 DOM 的更新发生在 commit 阶段——此时新 DOM 还没创建/更新,ref.current还是旧值(或没值)。所以「读 DOM」必须在useEffect(绘制后)或useLayoutEffect(绘制前,DOM 已更新)里做。
一句话速记
一次更新分两阶段:render(可中断、必须纯、算出变更、收集 flags) 与 commit(不可中断:
before mutation→mutation改 DOM + layout 清理 →layout赋 ref +useLayoutEffect),之后是浏览器绘制,最后异步执行useEffect;顺序口诀是「render 由外到内,清理由内到外,effect 由内到外」;render 必须纯是因为它可能被重放或丢弃;读 DOM 要放 commit 后(useLayoutEffect绘制前 /useEffect绘制后);commit 不能中断(否则用户看到改了一半的界面),所以里面的代码绝不能慢。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!