前端开发··1 阅读·预计 10 分钟

React 状态更新的批处理与边界:从自动批处理到 useSyncExternalStore 的同步性治理

引言

React 里的「一次 setState 触发一次渲染」是个流传很广的误解。真实语义更微妙:同一次回调里的多次更新会被批处理成一次渲染,但一旦跨出 React 能拦截的边界(比如 setTimeout、原生事件、外部 store 更新),批处理就失效,更新变回逐条同步。本文不重复「为什么用函数组件」,只拆这条批处理边界的治理,让渲染次数变得可预测。

一、React 18 的自动批处理:一个回调内合并成一次渲染

React 18 之前,批处理只在事件处理器里生效;React 18 起,自动批处理扩展到 Promise、setTimeout 等所有 React 能接管的位置。

// 正例:同一个事件处理里的两次 setState,合并成一次渲染
function Counter() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount((c) => c + 1);
    setFlag((f) => !f);
    // 两次更新被批处理,组件只重渲染一次
  }

  return <button onClick={handleClick}>click</button>;
}
// 正例:React 18 起,异步回调里的更新同样被自动批处理
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    fetchData().then(() => {
      setCount((c) => c + 1);
      setCount((c) => c + 1); // 同为一次批处理,两次都基于前值累加
    });
  }

  return <button onClick={handleClick}>click</button>;
}

理解批处理的意义不只是「少渲染几次」,更在于它决定「读到的中间态」。批处理内的多次 setCount,用函数式更新能正确累加;若误用闭包捕获的旧值,仍会丢失更新。

二、批处理的边界:原生事件与第三方回调会「漏出来」

自动批处理只在 React 能「看到」的更新路径里生效。一旦更新发生在 React 事件系统之外,它就无法合并。

// 反例:原生事件里连续 setState,批处理失效,逐条同步
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const el = document.getElementById('native');
    el.addEventListener('click', () => {
      setCount((c) => c + 1);
      setCount((c) => c + 1); // 原生事件不被 React 接管,两次分别渲染
    });
  }, []);

  return <div id="native">click</div>;
}

原生 addEventListener 的回调不经过 React 的合成事件系统,React 无法在这里做批处理,两次 setCount 各自触发一次渲染。

// 正例:需要批处理时,用 ReactDOM.flushSync 或把更新统合回 React 事件
import { flushSync } from 'react-dom';

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

  function handleNative() {
    // 显式包一层 flushSync,强制同步刷新并合并本次更新
    flushSync(() => {
      setCount((c) => c + 1);
      setCount((c) => c + 1);
    });
  }

  return <div onClick={() => handleNative()}>click</div>;
}

flushSync 是反向操作:强制 React 立即同步处理这次更新,绕过批处理。它适合「更新后必须同步读 DOM」的场景,但滥用会破坏并发渲染的收益,应作为例外而非默认。

三、useSyncExternalStore:外部数据源的一致性快照

当状态来自 React 之外的 store(Redux、Zustand、浏览器 API 等),最大的坑是「撕裂(tearing)」:并发渲染下,组件读到的外部状态可能不一致。useSyncExternalStore 就是为解决这个而生的。

// 反例:用 useState + useEffect 订阅外部 store,存在撕裂与多余渲染
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return width;
}

useState 订阅外部源,React 无法知道这个值「何时变了、是否变了」,既可能错过快速连续的更新,也可能在并发渲染里读到不一致的快照。

// 正例:useSyncExternalStore 订阅 + 快照,保证一致性与按需渲染
import { useSyncExternalStore } from 'react';

function subscribe(cb: () => void) {
  window.addEventListener('resize', cb);
  return () => window.removeEventListener('resize', cb);
}

function getSnapshot() {
  return window.innerWidth;
}

function useWindowWidth() {
  return useSyncExternalStore(subscribe, getSnapshot);
}

useSyncExternalStore 明确区分「订阅变更」与「读取当前快照」,React 据此决定何时重渲染、如何保证并发下的一致性。外部数据源接入 React,这条 API 是唯一的正确姿势。

四、更新语义的治理清单

把批处理与同步性的边界收敛成可执行的判断,避免「渲染次数靠猜」:

// 正例:更新语义治理清单
// 1. 批处理内的多次 setState:用函数式更新,别用闭包捕获旧值
// 2. 需要合并的更新:保持在 React 事件/Promise 路径内
// 3. 原生/第三方回调里的更新:要么接受逐条渲染,要么显式 flushSync
// 4. 外部数据源:一律 useSyncExternalStore,别用 useState 硬凑

// 反模式速查
//   批处理内 setState(c + 1):闭包旧值,更新丢失
//   原生事件里以为会自动批处理:结果渲染次数翻倍
//   用 useState+useEffect 订阅外部源:撕裂 + 多余渲染
//   不必要时滥用 flushSync:破坏并发渲染收益

这四条的共同主题,是让「更新发生了几次、什么时候被提交」从运行时行为变成可推理的设计决策——而不是靠加 console.log 数出来。

结语

React 状态更新的关键,从来不是「用了哪个 API」,而是「更新发生在 React 可见的边界内、还是边界外」。边界内,自动批处理帮你合并、函数式更新帮你正确累加;边界外,你得主动用 flushSyncuseSyncExternalStore 把一致性赎回。当你把「批处理边界」和「同步性」当成两个需要主动治理的维度,而不是依赖框架的默认行为时,渲染次数才真正从「玄学」变成「可预测的工程事实」。

0 评论

评论区

登录 后参与评论