陌上人如玉
公子世无双

4.7 批处理

4.7 批处理

一句话:React 会把「同一个事件循环里发生的多次 setState」合并成一次渲染——这叫批处理;React 18 之前只在合成事件里批处理,React 18+ 在所有场景(含 setTimeout、Promise、原生事件)都自动批处理。

一、什么是批处理

function Counter() {
  const [count, setCount] = useState(0);
  console.log('渲染');                 // 观察渲染次数

  const handleClick = () => {
    setCount((c) => c + 1);            // 三次 setState
    setCount((c) => c + 1);
    setCount((c) => c + 1);
  };

  return <button onClick={handleClick}>{count}</button>;
}
// 点击一次 → 打印【一次】「渲染」→ 显示 3
// 三次 setState 被【合并】成一次渲染
批处理的价值:

① 减少渲染次数(3 次 → 1 次)
② 避免「中间态」出现在界面上
   (比如同时 setLoading(true) 和 setData(x),用户不会看到「loading 且旧数据」)
③ 减少 commit 阶段的 DOM 操作与布局计算
④ 避免「渲染 → 再渲染 → 再渲染」导致的布局抖动(layout thrashing)

二、React 17 与 18 的差异(重点)

// React 17:
const handleClick = () => {
  // ① 在【合成事件】里 → 批处理 ✅
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // → 一次渲染

  // ② 在【异步回调】里 → 不批处理 ❌
  setTimeout(() => {
    setCount((c) => c + 1);      // 一次渲染
    setFlag((f) => !f);          // 又一次渲染
  }, 0);

  // ③ 在【原生事件监听】里 → 不批处理 ❌
  // ④ 在 Promise.then 里 → 不批处理 ❌
};
// React 18:
const handleClick = () => {
  // ① 合成事件 → 批处理 ✅
  setCount((c) => c + 1);
  setFlag((f) => !f);

  // ② setTimeout → ⭐ 也批处理 ✅(自动批处理 / Automatic Batching)
  setTimeout(() => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
  }, 0);

  // ③ Promise.then → ✅ 批处理
  fetch('/api').then(() => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
  });

  // ④ 原生事件监听 → ✅ 批处理
  // ⑤ 任何「同一个事件循环里」的多次更新 → ✅ 批处理
};
场景 React 17 及之前 React 18+
合成事件(onClick 等) ✅ 批处理 ✅ 批处理
setTimeout / setInterval ❌ 各触发一次渲染 ✅ 批处理
Promise.then / async/await ❌ ✅
原生事件监听 ❌ ✅
useEffect 里 ✅(本身在批处理上下文) ✅
同一个事件循环里的多次更新 部分 全部
React 18 的「自动批处理」是怎么做到的?

① React 自己实现了一套调度,不再依赖「React 事件系统」这个上下文
② 更新的入队会「按优先级分车道(lane)」并安排一次调度
③ 在同一次「宏任务」里入队的更新会落在同一个批次里
   → 调度器在微任务/下一帧统一执行这些更新
④ 因此「在什么地方调用 setState」不再影响是否批处理

⚠️ 前提:必须使用 createRoot(React 18 的新入口)。
   如果还在用 ReactDOM.render(legacy 模式),自动批处理【不生效】。

三、批处理的边界

// ❌ 跨「宏任务」的更新不会被批处理(本来就不该期待被批处理)
const handle = async () => {
  setCount((c) => c + 1);          // 批次 1

  await delay(1000);                // ⚠️ 宏任务结束

  setCount((c) => c + 1);          // 批次 2(另一次渲染)
};
// ⚠️ 两个「不同的 await 之间」的更新会分开
const handle = async () => {
  const a = await api1();           // await 之后是新的一次微任务延续
  setCount(a);

  const b = await api2();
  setFlag(b);                       // → 另一次渲染
};
// 因为两次 setState 分别落在「不同的微任务」里

// ✅ 如果希望合并,可以把结果先存起来,最后一起 setState
const handle2 = async () => {
  const [a, b] = await Promise.all([api1(), api2()]);
  setCount(a);
  setFlag(b);                       // ⭐ 同一次微任务 → 合并成一次渲染
};
结论:
  「同一个宏任务(或同一段连续同步代码)里的多次更新」→ 批处理
  「被 await / setTimeout 分隔开的更新」→ 各自成批次

⚠️ 但 React 18 之后,即使是「setTimeout 回调内部」的多行 setState
   也会被批处理(因为都在同一个回调的同步代码里)。

四、从「批处理」理解 setState 的「异步」

function Demo() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    console.log(count);              // ⚠️ 0(不是 1)
  };
  return <button onClick={handleClick}>{count}</button>;
}
准确的表述(面试要能说清):

① setCount 调用本身是【同步】的
   —— 它只是把「更新」放进队列(并安排一次调度)

② 「计算新状态 + 渲染 + 提交」是【异步/延后】的
   —— 在批处理结束后统一执行

③ 所以在事件处理函数内部读 count 得到的是【本次渲染的快照值】

④ 下一次渲染时,useState 会返回新的值

所以「setState 是同步还是异步」这个问题本身不严谨,
应该说:「调用是同步的,应用更新是批处理延后的」。
// 想在「当前这次」读到最新值 → 用函数式更新
const handleClick = () => {
  setCount((c) => {
    const next = c + 1;
    console.log('最新值', next);      // ✅ 1(在「计算时」读,是最新的)
    return next;
  });
};

// 或者用 useEffect「监听」状态变化
useEffect(() => {
  console.log('count 变成了', count);  // ✅ 在渲染之后执行
}, [count]);

五、flushSync(强制同步刷新)

import { flushSync } from 'react-dom';

// ⚠️ 会「跳出批处理」,立即把更新同步应用并渲染
function handleClick() {
  flushSync(() => {
    setCount((c) => c + 1);
  });
  // ⭐ 到这里 DOM 已经更新了

  flushSync(() => {
    setFlag((f) => !f);
  });
  // ⭐ 又一次同步更新
}
什么时候需要 flushSync?

场景:更新之后需要「立即读取 DOM」

① 打印/导出:先 setState 显示要打印的内容,然后立即 window.print()
   ❌ 不 flushSync:DOM 还没更新就打印 → 打印到旧内容
   ✅ flushSync(() => setPrintContent(html)) 然后 window.print()

② 测量:需要「更新后立刻拿到新尺寸」
   (但这种情况通常用 useLayoutEffect 更合适)

③ 与「非 React 的库」集成:库要求「你更新完我马上读 DOM」

⚠️ 代价与风险:
① 破坏批处理 → 多次渲染(性能下降)
② 可能导致「渲染还没结束就写 DOM」的警告
③ 在渲染过程中调用会报错(只能在事件处理 / Effect 里用)
④ 过度使用会让「数据流」难以预测

所以:能不用就不用,优先考虑 useLayoutEffect 或 useEffect。

六、批处理与「渲染次数」的实测

// 用一个可观察的例子验证(推荐自己跑)
function BatchDemo() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const renderCount = useRef(0);
  renderCount.current++;

  const updateBoth = () => {
    setA((x) => x + 1);
    setB((x) => x + 1);
    // → 只渲染 1 次(渲染计数 +1,不是 +2)
  };

  const updateAsync = () => {
    setTimeout(() => {
      setA((x) => x + 1);
      setB((x) => x + 1);
      // React 18:仍然只渲染 1 次
      // React 17:渲染 2 次
    }, 0);
  };

  const updateAcrossAwait = async () => {
    setA((x) => x + 1);
    await delay(0);              // ⚠️ 跨了微任务
    setB((x) => x + 1);
    // → 渲染 2 次(两个批次)
  };

  return (
    <>
      <p>渲染次数:{renderCount.current}</p>
      <button onClick={updateBoth}>同步更新两个</button>
      <button onClick={updateAsync}>异步更新两个</button>
      <button onClick={updateAcrossAwait}>跨 await 更新</button>
    </>
  );
}

七、常见坑

// ① 以为「React 18 之后所有更新都会合并成一次」
// → 被 await / 多个宏任务分隔的更新依然各自成批

// ② 在 legacy 模式(ReactDOM.render)下期待自动批处理
ReactDOM.render(<App />, root);      // ⚠️ 自动批处理不生效
// ✅ createRoot(root).render(<App />)

// ③ 在事件处理里读 setState 后的新值
setCount(count + 1);
console.log(count);                  // ❌ 旧值

// ④ 用「临时变量」绕过批处理
let next = count + 1;
setCount(next);
console.log(next);                   // ⚠️ 这是你自己算的值,不是 state 的值

// ⑤ 期待「批处理能跨 await」
// ⑥ 用 flushSync 做「小事」(如只是想读一个值)→ 得不偿失
// ⑦ 在渲染阶段调用 flushSync → 报错
// ⑧ 认为「批处理是性能优化,所以不批处理是 bug」
// → 它是实现细节层面的行为,业务逻辑不应该依赖「渲染了几次」

// ⑨ 在 useEffect 里连续 setState 期待「一次渲染」
useEffect(() => {
  setA(1);
  setB(2);
  // ✅ 同一次 Effect 内的会合并(一次渲染)
}, []);

// ⑩ 用「渲染计数」判断「逻辑是否正确」(渲染次数是优化细节,不该被依赖)
// ⑪ 在第三方库的回调里连续 setState 却发现渲染两次
// → 可能跨越了「原生事件 → 异步 → 另一个宏任务」的边界
// ⑫ 认为「批处理会让 setState 的更新被丢弃」(不会,只是延后)

八、面试延伸

  1. 「什么是批处理?」
    批处理指「把同一个事件循环里发生的多次状态更新合并成一次渲染」。比如在一次点击里调用三次 setCount,React 只会重新渲染一次(最终值正确,但只渲染一遍)。它的价值是:减少渲染次数、避免中间态出现在界面上、减少 DOM 操作与布局计算。React 18 起批处理是「自动」的——不管 setState 是在事件处理、setTimeout、Promise 还是原生事件回调里。

  2. 「React 18 的自动批处理改了什么?」
    React 17 及之前,只有 React 事件处理函数里的更新会被批处理;在 setTimeout、Promise.then、原生事件监听里的多次更新会各自触发一次渲染。React 18 起(需使用 createRoot),所有更新都自动批处理(无论在哪里触发,只要在同一个事件循环里)。这也让「setState 是同步还是异步」这个老问题的答案变得更统一。

  3. 「setState 是同步还是异步的?」
    更准确的表述是「调用是同步的,应用更新是批处理延后的」。setState 调用时会同步地把更新入队(并安排调度),但「计算新状态 → render → commit」发生在批处理结束时(异步/延后)。所以在事件处理函数内部读 state 得到的是当次渲染的快照值;要在「下一次渲染」拿到新值就用 useEffect,要在「计算时」拿到最新值就用函数式更新 setCount(c => c + 1)。

  4. 「批处理的边界在哪里?」
    边界是「同一个宏任务 / 同一段连续同步代码」。① 同一个事件处理函数里的多次 setState → 合并;② 同一个 setTimeout 回调里的多次 setState → 合并(React 18);③ 被 await 或不同宏任务分隔的更新 → 各自成批次(如 setA(); await x; setB(); 会渲染两次)。想合并跨 await 的更新,可以在最后统一 setState(如先 Promise.all 取结果,再一次性更新)。

  5. 「为什么 React 18 之前不批处理异步回调里的更新?」
    因为早期 React 判断「是否在批处理上下文」的方式是「是否处于 React 事件系统的调度中」——合成事件处理函数执行时 React 会设置一个标志位,回调结束后统一 flush。而 setTimeout / Promise 的回调不在这个上下文里,所以每次 setState 都会立即(同步)走一遍「调度 → 渲染」。React 18 重构了调度机制(不依赖事件系统,改用微任务统一的批处理),因此不再有这个限制。

  6. 「flushSync 是干什么的?什么时候用?」
    flushSync(callback) 会跳出批处理,立即同步执行 callback 里的更新并完成渲染(DOM 在函数返回前就已更新)。它主要用于「更新后必须立刻读取 DOM」的场景,典型的是「打印」:先 flushSync(() => setPrintContent(html)),然后立即调用 window.print(),否则打印的会是旧内容。但它的代价是破坏批处理(多次渲染、性能下降),所以能不用就不用——优先考虑 useLayoutEffect。

  7. 「批处理会导致更新被丢弃吗?」
    不会。批处理只是延后执行,不会丢弃任何更新——同一个 state 的多次更新会被按顺序应用(所以 setCount(c => c+1) 三次最终会 +3)。这也是为什么「连续三次 setCount(count + 1) 只加 1」不是「更新被合并丢了两个」,而是「三次传入的值都是同一个旧值计算出来的 1」。用函数式更新就能得到 1、2、3 的效果。

  8. 「批处理对我们写业务代码有什么影响?」
    三点:① 不要在事件处理里依赖「读到的 state 是新值」——用函数式更新或 useEffect;② 不要依赖「渲染次数」(它是优化细节,React 有权调整);③ 需要「更新后立刻读 DOM」时用 useLayoutEffect 或 flushSync(后者慎用)。此外,了解批处理有助于理解「为什么既有 3 次 setState 却只渲染一次」以及「为什么跨 await 的更新会渲染两次」。

一句话速记

批处理 = 「同一事件循环里的多次更新合并成一次渲染」(减少渲染、避免中间态);React 17 及之前只在合成事件里批处理(setTimeout/Promise/原生事件里各触发一次),React 18+ 全场景自动批处理(前提是用 createRoot);被 await 或多个宏任务分隔的更新仍会分成多个批次;所以准确说法是「setState 调用同步、应用更新批处理延后」,要读最新值就用函数式更新 set(c => ...);flushSync 能强制同步刷新(用于「更新后立刻读 DOM」,如打印),但会破坏批处理,慎用。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.7 批处理

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫