陌上人如玉
公子世无双

4.3 渲染的两个阶段

4.3 渲染的两个阶段

一句话: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)

八、面试延伸

  1. 「React 渲染分哪两个阶段?」
    ① render 阶段(也叫 reconcile/协调阶段)——遍历 Fiber 树、执行组件函数、diff 出变化、收集副作用标记;特点是可中断、可丢弃、可重放,因此必须纯粹(不能有副作用);② commit 阶段——把算出的变化一次性应用到真实 DOM,分为 before mutation / mutation / layout 三个子阶段,不可中断。之后是浏览器绘制,最后异步执行 useEffect。

  2. 「useEffect 和 useLayoutEffect 分别在哪个阶段执行?」
    useLayoutEffect 在 commit 阶段的 layout 子阶段执行——此时 DOM 已更新但浏览器还没绘制,因此它会阻塞绘制,适合「读取布局并立即修正以防闪烁」;useEffect 在浏览器绘制之后执行(异步),不阻塞绘制,适合「请求、订阅、日志」这类不需要立刻完成的工作。

  3. 「为什么 render 阶段必须是纯函数?」
    因为它可能被中断后重放,也可能被整体丢弃。如果里面有副作用:① 会被执行多次(重复请求、重复计数);② 已执行的副作用无法撤销(影响用户);③ 会破坏 React「可中断渲染」「Suspense」「并发特性」的前提假设。所以规则是「render 只做计算,副作用放 Effect 或事件处理」。StrictMode 的双调用就是用来故意暴露不纯 render 的。

  4. 「为什么 useEffect 要在绘制之后执行?」
    因为大多数副作用(请求、日志、订阅)不需要阻塞绘制——让用户尽快看到界面更重要。把它放在绘制后异步执行,可以缩短「首屏可见时间」。反过来说,如果某个副作用的结果必须「在用户看到界面前就应用」(如测量尺寸后调整样式),它就不能放这里,要用 useLayoutEffect。

  5. 「commit 阶段为什么不能中断?」
    因为 commit 是「把变更真正写到 DOM 上」的过程。如果中途停下来,用户会看到改了一半的界面(比如列表删了一半、样式只应用了一部分)——这比「晚一点更新」糟糕得多。所以 React 的设计是「render 可以慢慢算(可中断),但一旦确定要提交,就必须一口气做完」。这也是为什么 commit 阶段里的代码绝不能慢。

  6. 「多个组件的 effect 执行顺序是怎样的?」
    三条规律:① 同一组件内按声明顺序执行(useLayoutEffect 先于 useEffect,因为阶段不同);② effect 的执行顺序是「子 → 父」(React 在 commit 时由下往上收集 effect);③ 清理函数的顺序与「执行顺序」相反(后进先出)。记忆口诀是「render 由外到内,清理由内到外,effect 由内到外」。

  7. 「为什么说 useLayoutEffect 会阻塞绘制?什么时候该用?」
    因为它运行在「DOM 已更新、但浏览器还没绘制」的窗口里——浏览器必须等它(以及它触发的同步重渲染)完成后才能绘制。所以它会延长「首屏可见时间」。该用的场景是「读取 DOM 布局信息并据此立即修正 UI」:Tooltip 定位、自适应宽度、滚动位置恢复、自动聚焦。判断标准是「晚一帧修正用户会看到闪烁吗」。

  8. 「在 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 不能中断(否则用户看到改了一半的界面),所以里面的代码绝不能慢。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.3 渲染的两个阶段

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫