React 18 并发渲染实战:useTransition、Suspense 与自动批处理深度解析
升级到 React 18 都多久了,项目里还在用
ReactDOM.render?并发特性的 API 文档读了三遍,感觉懂了,但实际上一行都没用上?本文用三个具体场景,带你把这些特性从概念变成能落地的代码。
背景:为什么 React 需要"并发"?
在 React 18 之前,渲染是同步且不可中断的。一旦触发状态更新,React 就会从头到尾把整棵组件树重新渲染一遍,期间浏览器无法响应任何用户输入。
这个问题在简单应用里感知不明显,但在数据量大、交互频繁的场景下,用户会明显感受到卡顿------输入框打字延迟、列表滚动掉帧、页面切换时一片空白。
React 18 引入了并发模式(Concurrent Mode) ,核心思想是:把渲染任务变成可中断、可恢复、可优先级调度的异步工作单元。高优先级更新(比如用户输入)可以打断低优先级渲染(比如大列表重新筛选),让界面始终保持响应。
本文聚焦三个最常用的并发特性:
useTransition:标记低优先级更新,避免界面因重计算而卡住Suspense:声明式处理异步加载状态,告别满屏 loading 判断- 自动批处理(Automatic Batching):减少不必要的重复渲染
一、useTransition:让界面永远先响应用户
问题场景
假设你有一个带搜索过滤的大列表,数据量 5000 条。用户每敲一个字,都要重新过滤渲染:
tsx
// ❌ 旧写法:每次 setState 都触发同步渲染,输入框明显卡顿
const [query, setQuery] = useState('');
const [list, setList] = useState(bigData);
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setQuery(val);
// 这行耗时很长,直接阻塞了上面 setQuery 的渲染
setList(bigData.filter(item => item.name.includes(val)));
}
输入框更新和列表过滤被捆绑在同一个渲染批次里,5000 条数据的过滤计算完成前,输入框的光标都不会动。
useTransition 解法
tsx
import { useState, useTransition } from 'react';
function SearchList({ bigData }: { bigData: Item[] }) {
const [query, setQuery] = useState('');
const [list, setList] = useState(bigData);
// isPending:是否有待处理的低优先级更新
const [isPending, startTransition] = useTransition();
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
// 高优先级:立即更新输入框显示
setQuery(val);
// 低优先级:标记为 Transition,可被中断
startTransition(() => {
setList(bigData.filter(item => item.name.includes(val)));
});
}
return (
<div>
<input value={query} onChange={handleInput} placeholder="搜索..." />
{/* 过滤期间给列表加个半透明遮罩,而不是冻结输入框 */}
<ul style={{ opacity: isPending ? 0.5 : 1 }}>
{list.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
startTransition 内部的状态更新会被标记为低优先级。当用户继续输入时,React 会丢弃上一次未完成的过滤渲染,重新基于最新输入值计算。输入框从此丝滑,列表更新则"尽力而为"。
一个容易踩的坑
startTransition 的回调必须是同步的 。不能在里面 await 异步请求:
tsx
// ❌ 错误:异步代码放进去不会被标记为 Transition
startTransition(async () => {
const data = await fetchData(query);
setList(data);
});
// ✅ 正确:在外部 async 函数中先等待请求,再用 startTransition 包裹同步更新
async function handleSearch(query: string) {
const data = await fetchData(query); // 先等待,在 startTransition 外面
startTransition(() => {
setList(data); // 这里只有同步的状态更新
});
}
二、Suspense:把异步加载写得像同步一样优雅
它解决什么问题
以前处理异步数据加载,代码是这样的:
tsx
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(userId)
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMsg error={error} />;
return <div>{user.name}</div>;
}
每个异步组件都要写一套 loading/error/success 三态逻辑,重复代码多,而且 loading 状态分散在各处,难以统一管控。
为什么 useEffect 无法触发 Suspense?
很多人第一次接触 Suspense 数据获取时会尝试这样写:
tsx
// ❌ 这样写不会触发 Suspense,useEffect 是在渲染之后才跑的
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => { fetchUser().then(setUser); }, []);
// React 不知道这个组件"正在等待数据",不会挂起它
return <div>{user?.name}</div>;
}
Suspense 的工作原理是:组件渲染时抛出(throw)一个 Promise ,React 捕获到这个 Promise 后把组件挂起,等 Promise resolve 后再重新渲染。useEffect 里的 fetch 是渲染完成之后才执行的,根本走不到这条路。
这就是为什么需要用支持 Suspense 协议的数据库,React Query 5.x 的 useSuspenseQuery 就实现了这个协议。
Suspense + React Query 组合
React 18 的 Suspense 配合 React Query 5.x(@tanstack/react-query)使用时,组件本身可以完全摆脱加载状态判断:
tsx
// 数据层:React Query 配置 suspense 模式
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
// 数据未就绪时,组件会"挂起",由外层 Suspense 接管
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// 走到这里数据一定存在,不需要任何 loading 判断
return <div>{user.name}</div>;
}
// 页面层:统一声明 fallback
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<GlobalSpinner />}>
<UserProfile userId="123" />
</Suspense>
</ErrorBoundary>
);
}
嵌套 Suspense:细粒度控制加载粒度
多个异步组件可以用嵌套的 Suspense 分别控制加载顺序:
tsx
function Dashboard() {
return (
<div className="dashboard">
{/* 头部信息先加载 */}
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<div className="content">
{/* 左右面板独立加载,互不干扰 */}
<Suspense fallback={<PanelSkeleton />}>
<LeftPanel />
</Suspense>
<Suspense fallback={<PanelSkeleton />}>
<RightPanel />
</Suspense>
</div>
</div>
);
}
这样 Header 加载完毕后就立即显示,无需等待 LeftPanel 和 RightPanel,用户感知到的加载速度明显更快。
三、自动批处理:隐形的性能优化
React 18 之前的批处理限制
React 17 里,只有 React 事件处理函数内部的多次 setState 会被合并(批处理)成一次渲染。一旦跳出 React 的事件系统,批处理就失效了:
tsx
// React 17:在 setTimeout 里触发两次渲染
setTimeout(() => {
setCount(c => c + 1); // 触发第一次渲染
setFlag(f => !f); // 触发第二次渲染,共两次!
}, 1000);
Promise 回调、原生事件监听器、setTimeout/setInterval 里的 setState 都会各自触发一次渲染,增加不必要的性能开销。
React 18 的自动批处理
升级到 React 18 并使用 createRoot 后,所有来源的状态更新都会自动批处理:
tsx
// main.tsx - 使用新的 createRoot API(必须)
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(<App />);
tsx
// React 18:无论在哪里,多个 setState 只触发一次渲染
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// ✅ 只渲染一次,React 自动合并这两个更新
}, 1000);
// fetch 回调同样适用
fetch('/api/data').then(() => {
setData(res);
setLoading(false);
// ✅ 只渲染一次
});
特殊情况:需要强制同步渲染
极少数情况下你确实需要每次 setState 都立即渲染(比如读取 DOM 尺寸后再更新状态),可以用 flushSync:
tsx
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1); // 立即同步渲染
});
// 这里可以读到最新的 DOM 状态
console.log(divRef.current.offsetHeight);
flushSync(() => {
setFlag(f => !f); // 再次同步渲染
});
}
flushSync 是逃生舱,不要滥用,它会破坏自动批处理的优化效果。
四、三个特性的适用场景总结
| 特性 | 适用场景 | 核心收益 |
|---|---|---|
useTransition |
大数据量的搜索过滤、Tab 切换、表格排序 | 输入响应零延迟,渲染不卡顿 |
Suspense |
路由懒加载、数据请求、代码分割 | 消除散落的 loading 判断,统一加载 UI |
| 自动批处理 | 所有项目(升级 createRoot 即可获得) | 减少无谓的重复渲染,提升整体性能 |
五、升级 React 18 的注意事项
1. 必须切换到 createRoot
自动批处理和并发特性只在 createRoot 模式下生效。继续使用 ReactDOM.render 的话,React 18 会以"遗留模式"运行,并发特性全部失效。
2. useEffect 在严格模式下会执行两次
React 18 的 StrictMode 在开发环境会刻意挂载→卸载→重新挂载组件,以暴露副作用清理的问题。上线后不影响,但本地开发会看到 useEffect 跑了两遍的现象,不要被吓到。
3. useDeferredValue:useTransition 的兄弟 API
如果无法修改状态更新逻辑(比如用的是第三方组件),可以用 useDeferredValue 包裹值,效果类似 useTransition,但作用在值而不是更新函数上:
tsx
const deferredQuery = useDeferredValue(query);
// 将 deferredQuery 传给耗时组件,而不是直接传 query
<HeavyList filter={deferredQuery} />
总结
React 18 的这三个特性,解决的是三个不同层面的问题。
自动批处理是最低成本的优化------换 createRoot 就完事,不需要改一行业务代码。useTransition 值得在搜索、大列表、Tab 切换这类场景里重点应用,体验提升是用户肉眼可见的。Suspense 则是需要团队一起接受的"新范式",一旦用起来,你会发现以前写的那些 loading 状态管理代码真的又冗余又难看。
不用一次性全改。先把 createRoot 切过去,然后找一个用户反映最卡的搜索页面,加上 useTransition,上线看效果。Suspense 可以从路由懒加载开始用,再逐步推进到数据请求。一步一步来,每一步都有实实在在的收益。
文章中的代码示例基于 React 18.3 + TypeScript 5 + React Query 5.x(@tanstack/react-query@5),均可在 Vite 脚手架项目中直接运行。