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 可见的边界内、还是边界外」。边界内,自动批处理帮你合并、函数式更新帮你正确累加;边界外,你得主动用 flushSync 或 useSyncExternalStore 把一致性赎回。当你把「批处理边界」和「同步性」当成两个需要主动治理的维度,而不是依赖框架的默认行为时,渲染次数才真正从「玄学」变成「可预测的工程事实」。
评论区
登录 后参与评论