React 并发模式下的状态更新批处理:从 automatic batching 到 Transitions 的深度拆解
一、React 18 的 automatic batching:不只是"少渲染几次"
React 18 引入的 automatic batching,官方文档用一句话概括:"React 会将多个状态更新合并为一次渲染"。这句话容易让人误解为 batching 只是减少渲染次数的优化手段。实际上,automatic batching 的本质是改变了状态更新的调度模型。
在 React 17 及之前,batching 只在 React 事件处理器内生效。Promise 回调、setTimeout、原生 DOM 事件中的多次 setState,每次都会触发独立渲染:
// React 17:非 React 事件中的 setState 不会批量处理
function handleClick() {
// React 事件处理器内:两次 setState 合为一次渲染
setCount(c => c + 1);
setFlag(f => !f);
// 只触发 1 次渲染
}
fetch('/api').then(() => {
// Promise 回调内:两次 setState 各触发一次渲染
setCount(c => c + 1); // 渲染 1
setFlag(f => !f); // 渲染 2
// 触发 2 次渲染
});
React 18 用 createRoot 替代 ReactDOM.render 后,所有上下文中的 setState 默认都会批量处理。这不是简单的"开关",而是底层调度器从同步执行改为异步调度的结果。每次 setState 不再直接进入 fiber 根节点的更新队列并立即调度,而是被收集到当前调度单元的更新集合中,等待调度器统一处理。
但 automatic batching 有一个边界条件:它无法区分更新的紧急程度。一个输入框的实时响应和一个后台数据刷新,在 automatic batching 中被同等对待——都等同一批处理完再渲染。这对输入延迟敏感的场景不友好。这正是 startTransition 存在的原因。
二、startTransition:让调度器区分紧急与非紧急更新
startTransition 的 API 签名极简:接收一个回调函数,回调内的 setState 被标记为"过渡性更新"。调度器对过渡性更新的处理策略:如果当前有紧急更新在排队,过渡性更新会被延迟处理;如果紧急更新在过渡性更新渲染过程中到达,过渡性渲染会被中断并放弃。
flowchart TB
A[用户输入: setSearchTerm] –> B{是否在 transition 内?}
B –>|否| C[紧急更新: 立即渲染输入框]
B –>|是| D[过渡性更新: 可中断的搜索结果渲染]
C –> E[调度器优先处理紧急更新]
D –> F[如果紧急更新到达, 中断当前过渡性渲染]
F –> G[重新调度过渡性更新]
E –> G
一个典型的使用场景:搜索框输入 + 搜索结果列表渲染。
import { startTransition, useState } from 'react';
function SearchPage() {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<Item[]>([]);
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
// 紧急更新:输入框的值必须立即反映用户输入
setSearchTerm(e.target.value);
// 过渡性更新:搜索结果的渲染可以被中断
startTransition(() => {
// 这个 setState 被标记为低优先级
// 如果用户在渲染过程中继续输入,此渲染会被中断
const results = performSearch(e.target.value);
setSearchResults(results);
});
}
return (
<div>
<input value={searchTerm} onChange={handleInputChange} />
<ResultList items={searchResults} />
</div>
);
}
startTransition 的工作原理:它不是简单的"延迟执行"。React 调度器维护两个更新队列——紧急队列和过渡队列。紧急队列的处理优先级更高。当过渡性更新正在渲染时,如果紧急队列有了新更新,调度器会中断当前渲染,丢弃未完成的过渡性渲染结果,重新从紧急更新开始调度。
这意味着过渡性更新不是"慢一点执行",而是"可以被随时丢弃"。这是一个重要的语义差异——过渡性更新中的副作用(如 DOM 操作、网络请求)不应该依赖"一定能执行完"的假设。
三、automatic batching 与 Transitions 的协同调度机制
automatic batching 和 Transitions 不是两个独立的优化,它们在调度器层面是协同工作的。理解它们的交互关系,需要看 React 的更新调度流程。
sequenceDiagram
participant U as 用户交互
participant S as 调度器
participant R as 渲染器
U->>S: setState(urgent) – 输入框值更新
U->>S: startTransition → setState(transition) – 搜索结果更新
S->>S: 收集同一批次的紧急更新
S->>R: 提交紧急更新渲染
R->>R: 渲染输入框(高优先级部分)
S->>S: 检查过渡队列
S->>R: 开始过渡性渲染
U->>S: 新的 setState(urgent) – 用户继续输入
S->>S: 中断当前过渡性渲染
S->>R: 提交新的紧急渲染
R->>R: 渲染最新输入框状态
S->>R: 重新开始过渡性渲染
R->>R: 渲染最新搜索结果
关键细节:automatic batching 在收集更新时,会根据 startTransition 的标记将更新分配到不同优先级队列。同一批次的紧急更新合并为一次渲染提交;同一批次的过渡性更新也合并,但提交时机取决于紧急队列的状态。
// 批处理与优先级的交互示例
function ComplexInteraction() {
const [inputValue, setInputValue] = useState('');
const [filteredData, setFilteredData] = useState<Data[]>([]);
const [sortBy, setSortBy] = useState('date');
const [isPending, startTransition] = useTransition();
function handleInputAndSort(e: React.ChangeEvent<HTMLInputElement>) {
// 第一组:紧急更新(automatic batching 合批)
setInputValue(e.target.value);
setSortBy('relevance'); // 与 setInputValue 同批,一次渲染
// 第二组:过渡性更新(独立批处理,可中断)
startTransition(() => {
const filtered = filterAndSort(allData, e.target.value, 'relevance');
setFilteredData(filtered); // 过渡性批处理
});
}
// isPending 反映过渡性更新的状态
// 当过渡性渲染被中断或进行中时为 true
return (
<div>
<input value={inputValue} onChange={handleInputAndSort} />
{isPending && <Spinner />}
<DataList data={filteredData} />
</div>
);
}
useTransition 返回的 isPending 是一个关键信号。它表示"当前有过渡性更新正在等待或被中断"。这不同于 isLoading——isPending 为 true 时,UI 仍然可以响应用户输入,只是后台的数据处理还在进行。
注意一个常见误用:不要把所有异步数据获取都放进 startTransition。只有那些"用户可以容忍短暂延迟"的更新才应该标记为过渡性。表单提交后的成功提示、支付确认页面的状态更新——这些属于紧急更新,强行放进 transition 反而会导致不可接受的交互延迟。
四、Transitions 的中断安全性与反模式规避
startTransition 的中断机制意味着过渡性更新中的渲染可能被多次丢弃。这带来两个必须处理的约束。
第一,副作用安全。过渡性更新渲染中不应包含不可中断的副作用。React 的 useEffect 执行时机在渲染提交后,不受中断影响。但 useLayoutEffect 在渲染阶段同步执行,如果渲染被中断,useLayoutEffect 的清理函数可能不会按预期执行。
// 反模式:在过渡性更新中使用 useLayoutEffect
function BadTransitionComponent({ data }) {
useLayoutEffect(() => {
// 如果此组件的渲染被中断,此 effect 的清理可能异常
const animation = startAnimation(data);
return () => animation.cancel(); // 中断时清理可能未执行
}, [data]);
}
// 正确做法:将副作用移到 useEffect 或紧急更新路径
function SafeTransitionComponent({ data }) {
useEffect(() => {
// useEffect 在渲染提交后执行,不受中断影响
const animation = startAnimation(data);
return () => animation.cancel();
}, [data]);
}
第二,数据一致性。过渡性更新使用的计算输入可能已经过时。当渲染被中断并重新调度时,组件会基于最新的 state 重新渲染。如果过渡性更新中使用了闭包捕获的旧值,重新渲染时这些值会自动更新为最新状态——因为 React 的渲染是基于当前 state 重新执行的。
// 过渡性更新中的闭包问题
function SearchWithTransition() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
startTransition(() => {
// 注意:这里的 e.target.value 是闭包捕获的值
// 如果渲染被中断,React 重新调度时会基于最新 query 重新渲染
// 但 setResults 使用的还是闭包中的旧值
const newResults = search(e.target.value);
setResults(newResults);
});
}
// 更安全的做法:使用 state 作为计算输入而非闭包值
function handleChangeSafe(e: React.ChangeEvent<HTMLInputElement>) {
setQuery(e.target.value);
startTransition(() => {
// 使用 state 驱动的计算,渲染中断后重新执行时使用最新 query
setResults(prev => search(query)); // query 是最新 state
});
}
}
此外,startTransition 不能用于控制外部系统的状态同步。比如 WebSocket 连接、IndexedDB 写入——这些操作一旦启动就无法"中断回滚"。过渡性更新只适用于纯 UI 渲染层面的延迟容忍场景。
五、总结
React 18 的状态更新调度机制,从"同步批处理"进化为"优先级驱动的异步调度"。automatic batching 消除了 React 17 中事件处理器外的批处理盲区,所有上下文的 setState 默认合批。startTransition 则在批处理之上引入优先级分层——紧急更新立即渲染,过渡性更新可中断、可丢弃。
两者的协同不是简单叠加。automatic batching 在收集更新时按优先级分流,紧急批和过渡批各自合并,调度器按紧急优先原则交替提交渲染。过渡性渲染过程中的中断,不是"暂停等待",而是"丢弃重来"——这对副作用和数据一致性提出了明确约束。
实践中,automatic batching 是默认行为,不需要显式干预。startTransition 的使用需要判断更新是否属于"可延迟容忍"类别。输入响应、表单提交反馈、支付确认——紧急。搜索结果渲染、大数据列表排序、非关键数据刷新——过渡。错误分类会导致交互体验劣化或数据不一致。
这个调度模型的本质:承认不同 UI 更新有不同的时效性要求,用优先级而非时间延迟来区分。这是并发模式区别于传统异步优化的核心设计决策。




