什么是批处理
批处理(batching)是 React 的一种性能优化机制:多个状态更新可以合并成一次重新渲染,而不是每次 setState 都触发一次。
考虑这个场景:
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
setCount(c => c + 1);
}
return <button onClick={handleClick}>{count}</button>;
}
点击一次按钮,count 应该增加 2,而不是 1。如果没有批处理,setCount(c => c + 1) 会触发一次渲染,setFlag(!f) 会触发第二次渲染,setCount(c => c + 1) 会触发第三次渲染。
有了批处理,React 会在 handleClick 这个事件处理函数执行完毕后,把所有的状态更新合并成一次渲染。最终 count 增加 2,flag 翻转一次,组件只重新渲染一次。
批处理在 React 18 之前和之后的区别
React 18 之前,批处理只在 React 的事件处理函数内部生效。原生事件绑定(addEventListener)或者异步代码(setTimeout、Promise.then)里的 setState 不会自动批处理。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.body.addEventListener('click', () => {
setCount(c => c + 1); // React 18 之前,这会触发单独的一次渲染
});
}, []);
return <div>{count}</div>;
}
在 React 18 之前,addEventListener 回调里的 setState 每次都会立即触发一次重新渲染。在 React 18 里,自动批处理扩展到了所有场景,包括 setTimeout、Promise.then、原生事件处理。
这就是为什么很多 React 18 的升级指南会说"某些依赖独立 setState 触发多次渲染的代码,在 React 18 里行为会变"——不是变 bug 了,而是批处理把多次渲染合并成了一次。
函数式更新和批处理的关系
回到之前提到的例子:
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
这个写法在批处理下有个问题:三次 setCount(count + 1) 里的 count 都是同一个值——当前渲染时的 count,因为它们都在同一个事件处理函数里,是同步执行的。React 会把这三次更新合并,最终 count 只增加 1。
如果想要每次基于前一次的结果累加,必须用函数式更新:
function handleClick() {
setCount(prev => prev + 1); // React 会排队处理
setCount(prev => prev + 1);
setCount(prev => prev + 1);
}
React 内部会维护一个更新队列,按顺序应用每一次 setCount(prev => prev + 1),所以最终 count 增加 3。
React 18 的 flushSync 和 urgent 更新
React 18 引入了更细粒度的优先级概念:urgent 更新和 transition 更新。
默认情况下,所有 setState 都是 urgent 更新。但 startTransition API 可以把一个状态更新标记为 transition 更新,告诉 React 这个更新不需要立刻反映到屏幕上。
import { startTransition } from 'react';
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function handleChange(e) {
const value = e.target.value;
setQuery(value); // urgent:输入框要立刻更新
startTransition(() => {
setResults(search(value)); // transition:结果列表可以延迟更新
});
}
}
setQuery 会立刻更新用户看到的输入框,setResults 可以稍微延迟,在后台慢慢计算。当用户快速输入时,这种区分让输入保持响应,而复杂的结果渲染不会被频繁的输入打断。
状态更新的优先级
React 内部用 lane 模型来管理更新的优先级。每个 setState 都会被分配一个 lane(可以理解为一种优先级标记),React 根据 lane 决定哪些更新应该先处理。
lane 模型是 Fiber 架构的一部分。Fiber 把渲染工作拆成了可中断的工作单元,每个工作单元有对应的优先级。紧急的用户交互(如点击、输入)有最高优先级,异步的、数据加载类的更新优先级较低。
理解这个机制对调试有帮助。比如一个常见的困惑是:为什么 setState 在某个场景下没有立刻更新 DOM?
答案通常是:那次更新被更高优先级的更新挤到了后面,React 正在处理更紧急的事情。等高优先级任务处理完毕后,低优先级更新才会继续执行。
useTransition 和 useDeferredValue
useTransition 和 useDeferredValue 是 React 18 引入的两个 Hook,用来处理并发渲染场景下的优先级问题。
useTransition 返回一个 isPending 标记和一个 startTransition 函数:
function App() {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function handleChange(e) {
setQuery(e.target.value);
startTransition(() => {
setResults(searchResults(e.target.value));
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending ? <Loading /> : <ResultsList results={results} />}
</div>
);
}
isPending 允许你在 transition 还在进行时显示一个 loading 状态,而用户依然能继续输入——输入框的更新是 urgent 的,结果列表的更新是 transition 的。
useDeferredValue 是另一种方式,用于处理"一个值变化了,但基于这个值的派生计算很重"的场景:
function Search({ query }) {
const deferredQuery = useDeferredValue(query);
// deferredQuery 可以稍微落后于 query
// 可以在这个值变化不剧烈时使用,比如渲染一个很大的列表
return <HeavyList query={deferredQuery} />;
}
这两个 Hook 的本质都是区分紧急和非紧急更新,让 React 能在并发渲染时保持界面的响应性。
这一章想说的
批处理是 React 的核心性能优化机制之一:多个 setState 在同一个事件处理函数里会合并成一次渲染,而不是各自触发一次。
React 18 把自动批处理扩展到了所有场景,包括原生事件绑定和异步代码。函数式更新 setCount(prev => prev + 1) 是保证在批处理下正确累加的必要方式。
React 18 还引入了 transition 和 urgent 更新的区分。startTransition 和 useDeferredValue 让你能告诉 React 哪些更新可以延迟处理,保持用户界面的响应性。这是 Fiber 架构和 lane 模型在应用层的体现。