陌上人如玉
公子世无双

12.2 setState 是同步还是异步

12.2 setState 是同步还是异步

一句话:setState 的「调用」是同步的,「应用更新并重新渲染」是批处理延后的——所以事件处理里读不到新值;React 18 起所有场景都自动批处理(不再有「异步回调里同步渲染」的例外)。

一、结论先行(三层回答)

问题:「setState 是同步还是异步?」

① 最简洁的回答:
   「setState 本身是同步的(它只是把更新入队),
     但『应用更新 + 重新渲染』是异步的(批处理后在事件处理结束时统一执行)。」

② 更准确的说法:
   「调用同步、渲染异步;React 18 起所有场景都自动批处理。」

③ 面试加分版:
   「这个问题的『同步/异步』本身不严谨——
    准确的划分是『入队是同步的、flush 是延后的』,
    而 flush 的时机取决于『优先级与调度』(并发渲染下更进一步:
    过渡优先级的更新可能被推迟到更晚)。」

二、代码验证(事件里读不到新值)

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

  const handleClick = () => {
    setCount(count + 1);
    console.log('事件处理里读到的 count =', count);      // ⚠️ 0(不是 1)

    // 强制同步刷新后再读
    flushSync(() => setCount((c) => c + 1));
    // ⚠️ 但这里 console.log 读到的仍然是「本次渲染的 count 变量」(它不会变)
  };

  return <button onClick={handleClick}>{count}</button>;
}
⭐ 关键理解:
① `count` 是「当次渲染闭包里的常量」——
   无论怎么 flush,这个变量在本次函数执行中都不会变
② `setCount` 的执行是同步的(入队完成)
③ 但「用新值重新渲染」发生在「事件处理函数执行完之后」
   → 所以「读不到新值」不是因为「setState 内部有 setTimeout」,
     而是因为「组件函数还没重新执行」
// ✅ 想「读到最新值」的正确姿势
const handleClick = () => {
  setCount((c) => {
    const next = c + 1;
    console.log('在更新函数里读到的 =', next);          // ✅ 1(这里是最新值)
    return next;
  });
};

// ✅ 或者在「下一次渲染」里读
useEffect(() => {
  console.log('count 变成了', count);                   // ✅ 渲染之后
}, [count]);

// ✅ 或者用 ref 同步一份
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);

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

// React 17:只有「React 事件处理」里才批处理
const handleClick = () => {
  setA(1);
  setB(2);
  // ✅ 批处理(一次渲染)

  setTimeout(() => {
    setA(3);        // ❌ 单独触发一次渲染
    setB(4);        // ❌ 再触发一次渲染
  }, 0);

  fetch('/api').then(() => {
    setA(5);        // ❌ 单独触发
    setB(6);        // ❌ 再触发
  });
};
// React 18:所有场景都自动批处理(需要 createRoot)
const handleClick = () => {
  setA(1);
  setB(2);
  // ✅ 一次渲染

  setTimeout(() => {
    setA(3);
    setB(4);
    // ✅ 也是一次渲染(自动批处理)
  }, 0);

  fetch('/api').then(() => {
    setA(5);
    setB(6);
    // ✅ 一次渲染
  });
};
场景 React 17 React 18(createRoot)
React 事件处理 ✅ 批处理 ✅ 批处理
setTimeout / setInterval ❌ 各自渲染 ✅ 批处理
Promise.then / async/await ❌ ✅
原生事件监听 ❌ ✅
同一个宏任务里的多次更新 部分 全部
跨 await 的更新 ❌ ❌(仍是多个批次)
⭐ React 18 是怎么做到的?
① React 不再依赖「React 事件系统上下文」来判断「是否在批处理」
② 更新入队时「按优先级分车道」并安排一次调度
③ 同一个「宏任务」里入队的更新会落在同一个批次
   → 调度器在微任务/下一帧统一 flush

⚠️ 前提:必须用 createRoot(legacy 的 ReactDOM.render 不享受自动批处理)

四、flushSync:强制同步刷新

import { flushSync } from 'react-dom';

function App() {
  const [items, setItems] = useState([]);
  const ref = useRef<HTMLDivElement>(null);

  const handleAdd = () => {
    // ⚠️ 场景:加完之后「立即读取 DOM 的新高度」
    flushSync(() => {
      setItems((prev) => [...prev, newItem]);      // ⭐ 同步渲染完成
    });

    // 到这里 DOM 已经更新了(可以读到新高度)
    console.log(ref.current?.scrollHeight);
  };

  return <div ref={ref}>{items.map((it) => <div key={it.id}>{it.text}</div>)}</div>;
}
⭐ flushSync 的典型场景与代价:

✅ 场景:
① 【打印】——先把「要打印的内容」同步渲染进去,再调 window.print()
   (否则打印的是旧内容)
② 【测量】——更新后立刻要读新尺寸(但这种情况通常用 useLayoutEffect 更合适)
③ 【与第三方库集成】——库要求「更新完立刻读 DOM」

⚠️ 代价与限制:
① 破坏批处理 → 多次渲染(性能下降)
② 在「渲染过程中」调用会报错(只能在事件处理/Effect 里用)
③ 会让「数据流」更难预测(过度使用是坏味道)

⭐ 所以:能不用就不用;优先「useLayoutEffect」或「把读 DOM 的时机挪后」。
⭐ `useLayoutEffect` 与 `flushSync` 的区别:
· `useLayoutEffect`:「DOM 更新后、绘制前」执行——
  用于「读取布局并立即修正」(如测量后 setState),避免闪烁
· `flushSync`:「在事件处理里」强制「立刻把更新刷进 DOM」——
  用于「更新后马上要做别的事并依赖新 DOM」
两者都「同步」,但「位置不同」:一个在 Effect 里,一个在事件处理里。

五、源码层面的解释(加分项)

一次 setState 经历了什么(简化):

① 【同步】更新入队
   setState → dispatchSetState → 
   在 fiber 的 hook.queue 上「入队一个 update 对象」
   → 计算优先级车道(Lane)
   → 调用 scheduleUpdateOnFiber(安排调度)

② 【同步】判断「能否立即 flush」
   如果「当前不在渲染中」且「优先级允许」→ 可能直接进入渲染流程
   (但通常会被「批处理」机制合并,等事件处理结束再统一处理)

③ 【异步/延后】调度渲染
   React 用 Scheduler 在「微任务/下一帧」执行渲染
   → 渲染 + commit 完成后,DOM 才真正更新

⭐ 所以「同步/异步」的准确描述是:
   「入队同步、flush 由调度器决定(批处理 + 优先级)」
⚠️ 一个历史细节(面试可能问):
React 17 及之前的批处理靠「isBatchingUpdates 标志位 + React 事件系统」:
· 合成事件处理时设置标志位 → setState 只入队
· 事件处理结束(batchedUpdates 回调返回)→ 统一 flush
所以在「React 事件系统之外」的 setState(setTimeout/Promise)会立刻 flush
→ 表现为「同步渲染」。

React 18 改成「基于调度的自动批处理」,不再依赖事件系统 →
所有场景都批处理。

六、常见追问

Q: 那「setState 之后立刻读 state」为什么读不到?
A: 因为「读的变量」是「本次渲染的闭包快照」——
   与 setState 是否同步无关,它就是个常量(React 的设计如此)。

Q: 为什么 React 要「批处理」?
A: ① 减少渲染次数(性能)
   ② 避免「中间态」出现在界面上(如「loading 且没有数据」的闪烁)
   ③ 减少 DOM 操作与布局抖动

Q: 同一个事件里 setCount(count+1) 三次为什么只加 1?
A: 因为三次传的都是「基于同一个快照算出的 1」→ 最终设为 1。
   用 setCount(c => c+1) 才会累加(因为计算被延迟到「应用更新时」)。
   (这也说明「函数式更新」是这个模型的必然要求。)

Q: 不加 flushSync 能实现「更新后立刻读 DOM」吗?
A: 能——用 useLayoutEffect(它本身就在「DOM 更新后、绘制前」执行)。
   flushSync 的价值主要在「事件处理里需要立刻有 DOM 结果」的场景(如打印)。

Q: 并发渲染下「异步」的表现会变吗?
A: 会更进一步:过渡优先级(useTransition/useDeferredValue)的更新
   可能被「推迟到更晚」甚至「被打断丢弃」。
   所以「异步」在并发下不只是「延后一次」,而是「优先级调度」。

Q: StrictMode 下的双调用会影响 setState 的行为吗?
A: 会让「组件函数执行两次」与「Effect 双执行」,但「更新本身」不会被应用两次
   (因为更新入队是按 action 走的,双调用只是「调用组件函数」两次)。

七、常见坑

// ① 在事件处理里期待读到新值
setCount(count + 1);
console.log(count);                         // ❌ 旧值(快照)

// ② 连续更新不用函数式
setCount(count + 1);
setCount(count + 1);                        // ❌ 只加 1

// ③ 用「临时变量」假装解决
const next = count + 1;
setCount(next);
console.log(next);                          // ⚠️ 这是你自己算的,不是 state

// ④ 认为「React 里的 setState 是异步的,所以内部用了 setTimeout」
// ⑤ 忘了 createRoot(legacy 模式没有自动批处理)
ReactDOM.render(<App />, root);             // ⚠️ 自动批处理不生效

// ⑥ 用 flushSync 处理「小事」(得不偿失)
// ⑦ 在渲染中调用 flushSync → 报错
// ⑧ 期待「跨 await 的更新被合并」
setA(1);
await delay();
setB(2);                                    // ⚠️ 两次渲染
// ⑨ 用「渲染次数」判断逻辑正确性(渲染次数是优化细节)
// ⑩ 在 useEffect 里连续 setState 期待「多次渲染」
useEffect(() => { setA(1); setB(2); }, []); // ✅ 同一次 Effect 里会合并
// ⑪ 用「setState 后立刻读 DOM」却没用 flushSync / useLayoutEffect
// ⑫ 认为「并发渲染让 setState 更『异步』了」——
//    ✅ 其实准确说是「优先级调度」,默认优先级下行为与 18 之前一致
// ⑬ 在「原生事件监听」里连续 setState 期待旧行为(React 18 会合并)
// ⑪ 的两种正确做法
// ✅ 方式一:useLayoutEffect(绘制前生效,不会闪)
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
  setHeight(ref.current!.offsetHeight);
}, []);

// ✅ 方式二:flushSync(事件里立即生效)
const handlePrint = () => {
  flushSync(() => setPrintMode(true));
  window.print();
};

八、面试延伸

  1. 「setState 是同步还是异步的?」
    这个问题本身不够严谨,准确的说法是「调用同步、flush 延后」:setState 调用时会同步地把更新入队(并安排一次调度),但「计算新状态 → 渲染 → 提交 DOM」是由调度器在批处理结束后执行的。所以在事件处理函数内部读 state 得到的是当次渲染的闭包快照(它是个常量);要拿到「最新值」应该用函数式更新(set(c => ...))或在 useEffect 里读。

  2. 「为什么连续三次 setCount(count + 1) 只加了 1?」
    因为 count 是「本次渲染闭包里的常量快照」(值为 0),三次调用传入的都是 0 + 1 = 1,React 收到「三次设为 1」的请求 → 最终结果是 1。改成 setCount(c => c + 1) 后,React 会在「应用每个更新时」把「最新的待处理值」传进函数 → 依次得到 1、2、3。这也是「函数式更新」在这个模型下必然存在的原因——它把「计算」从「调用时」推迟到「应用时」。

  3. 「React 18 的自动批处理改了什么?」
    React 17 及之前,批处理依赖「React 事件系统的上下文」——只有在「合成事件处理函数」里的更新会被合并,而 setTimeout、Promise.then、原生事件监听里的多次更新会各自触发一次渲染。React 18 起(需 createRoot),React 改用「基于调度的自动批处理」——只要在「同一个宏任务」里入队的更新就会合并,不再依赖事件系统。注意:被 await 或多个宏任务分隔的更新依然分成多个批次。

  4. 「flushSync 是干什么的?什么时候用?」
    flushSync(callback) 会「跳出批处理」,同步执行 callback 里的更新并完成渲染(函数返回时 DOM 已更新)。典型场景:① 打印——先 flushSync(() => setPrintContent(html)) 再 window.print()(否则打印旧内容);② 更新后立刻读 DOM(但这种情况通常 useLayoutEffect 更合适);③ 与「要求立即看到 DOM」的第三方库集成。代价是「破坏批处理 → 多次渲染、性能下降」,所以能不用就不用。

  5. 「为什么 React 要批处理?」
    三个理由:① 性能——减少渲染次数(3 次 setState → 1 次渲染);② 避免中间态——如果分别渲染,用户可能看到「loading=true 但数据还是旧的」这类非法中间状态(界面闪烁);③ 减少 DOM 操作与布局抖动——多次渲染会触发多次「样式计算 + 布局」,合并后只做一次。此外批处理还让「一次交互只产生一次重渲染」成为默认行为,降低了开发者的心智负担。

  6. 「不加 flushSync,怎么在更新后立刻读 DOM?」
    用 useLayoutEffect ——它本身就在「DOM 已更新、浏览器还未绘制」的时机执行,因此「在里面读 DOM」能拿到新值,且「在里面 setState」会在绘制前同步完成(不会闪)。flushSync 与它的区别是「位置不同」:useLayoutEffect 是「在 Effect 阶段」,flushSync 是「在事件处理里强制刷」。所以「测量尺寸后调整样式」应该用 useLayoutEffect;「先渲染再打印」才需要 flushSync。

  7. 「并发渲染让 setState 更『异步』了吗?」
    更准确的说法是「它有了优先级」。在 React 18 之前,「异步」只有「批处理延后一次」这一种含义;React 18 起,更新会按「优先级车道」调度:① Sync/Default 优先级(普通 setState)——行为与之前一致(批处理后尽快执行);② Transition 优先级(useTransition/useDeferredValue)——可能被推迟到更晚,甚至「被打断、丢弃」。所以「异步」在并发下不仅是「延后」,还有「谁先做、能不能被打断」的语义。

  8. 「StrictMode 的双调用会影响 setState 吗?」
    不会让「更新被应用两次」。StrictMode 的「双调用」是「把组件函数执行两次」(用来暴露不纯的渲染),但「更新的入队」是按「你调用了多少次 setState」走的——组件函数被调用两次不代表「更新被入队两次」。不过要注意:如果在「渲染过程中调用 setState」(错误做法),双调用会「让这个错误更明显」(可能直接触发无限循环的报错),这正是 StrictMode 的价值。

一句话速记

准确说法是「setState 调用同步(只是入队)、应用更新与重新渲染由调度器延后(批处理)」——所以事件处理里读到的是「闭包快照」(用函数式更新 set(c => ...) 或 useEffect 才能拿到最新值);连续三次 setCount(count+1) 只加 1 是因为三次传的都是同一个旧值算出的 1;React 17 只在合成事件里批处理(setTimeout/Promise/原生事件各自渲染),React 18 起全场景自动批处理(前提 createRoot,但跨 await 仍分批);flushSync 能「强制同步刷新」(打印、更新后立刻读 DOM 时用),代价是破坏批处理;「更新后立刻读 DOM」优先用 useLayoutEffect;批处理的三个目的是「减少渲染、避免中间态、减少布局抖动」;并发渲染下「异步」更进一步变成「优先级调度(可被推迟/打断)」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 12.2 setState 是同步还是异步

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫