并发是什么
并发(Concurrency)不是 React 的一个新功能,而是一种新的执行模式。React 18 之前,React 的渲染是同步的,一次渲染必须完全完成才能进行下一次。React 18 引入了并发模式,让 React 能够同时处理多个任务,并根据优先级来调度它们的执行。
理解并发的关键不是"并行执行 JavaScript"(JavaScript 依然是单线程的),而是React 能够在渲染过程中暂停、恢复和中断。这让 React 能够在用户快速输入时保持界面响应,在数据加载时显示 loading 状态而不阻塞主线程。
Dan Abramov 在 2018 年的介绍文章里用了一个很形象的比喻:并发模式下的 React 就像一个厨师,能够在煮汤的时候停下来去处理一个紧急的调味需求,然后回来继续煮汤,而不是在处理完整个汤之前不能做其他事情。
Suspense 解决的是什么问题
Suspense 是 React 用来处理异步数据加载的机制。它解决的是:在数据还没准备好的时候,React 应用如何优雅地展示 loading 状态。
在 Suspense 出现之前,异步数据加载通常是这样写的:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Loading />;
if (!user) return <Error />;
return <div>{user.name}</div>;
}
这段代码有几个问题:loading 状态需要手动管理,错误处理需要单独的状态,容易出现竞态条件(如果 userId 快速变化,请求返回的顺序可能不对)。
Suspense 提供了另一种写法:
function UserProfile({ userId }) {
const user = useSuspenseQuery(() => fetchUser(userId));
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile userId={id} />
</Suspense>
);
}
useSuspenseQuery 会 throw 一个 Promise,React 捕获到这个 Promise 后,就会显示 fallback,等 Promise resolved 后再渲染 UserProfile。
这种方式的优雅之处在于:loading 状态和错误处理都被 React 框架层接管了,组件只需要关注"数据是什么"。
Suspense for Code Splitting
Suspense 最初的应用场景是代码分割(Code Splitting):
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
);
}
React.lazy 动态导入一个组件,当组件还在加载时,React 会显示 fallback。当加载完成后,HeavyComponent 才会被渲染。这让首屏加载更快,而加载过程中的 loading 状态由 Suspense 自动处理。
useTransition 和并发渲染
useTransition 让你能把一个更新标记为非紧急的 transition:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(query) {
// urgent 更新
setQuery(query);
// transition 更新
startTransition(() => {
setResults(searchResults(query));
});
}
return (
<div>
<input onChange={e => handleSearch(e.target.value)} />
{isPending ? <Spinner /> : <ResultsList results={results} />}
</div>
);
}
当用户快速输入时,setQuery 作为 urgent 更新会立即反映到输入框,而 setResults 作为 transition 更新可以被中断。用户感觉输入是即时的,而搜索结果是"在后台"慢慢渲染的。
useDeferredValue
useDeferredValue 是另一个处理并发的 Hook,用法略有不同:
function Search({ query }) {
const deferredQuery = useDeferredValue(query);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList query={deferredQuery} />
</div>
);
}
deferredQuery 会落后于 query,当 query 快速变化时,deferredQuery 可以保持旧值一段时间。这意味着 <ResultsList> 不需要每次 query 变化都重新渲染,可以延迟到稳定后再渲染。
适用于:
- 父组件传来的值变化很快,但消费这个值的子组件渲染成本很高
- 子组件不需要实时反映父组件的值,可以稍有延迟
并发模式下的状态问题
并发模式引入了一个新问题:竞态条件(Race Condition)。
当一个请求还在进行中时,如果用户又发起了另一个请求,旧的请求可能比新的请求返回得更晚,导致 UI 显示的是陈旧的数据。
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]);
return <div>{user?.name}</div>;
}
如果 userId 从 1 变成 2,请求 A(userId=1)和请求 B(userId=2)同时发出,如果 A 比 B 慢,UI 会显示 userId=1 的用户数据。
解决方案是使用库(如 React Query、SWR)来自动处理竞态条件,或者在组件内用 AbortController 取消旧请求。
React 18 的 Automatic Batching
React 18 之前,批处理只在事件处理函数内部生效。React 18 把自动批处理扩展到了所有场景:
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
// React 18 之前:setTimeout 里的 setState 不会批处理
// React 18 之后:所有 setState 都会自动批处理
setTimeout(() => {
setCount(1);
setFlag(true);
}, 1000);
// 只有一次渲染,而不是两次
}
Automatic Batching 减少了不必要的渲染,提升了性能,而且让状态更新逻辑更容易写——你不需要关心"这个 setState 会触发几次渲染"。
这一章想说的
React 并发模式让渲染可以被暂停、恢复和中断,使得 React 能够在用户快速输入时保持界面响应,在数据加载时优雅地降级。
Suspense 为异步数据加载提供了一种声明式的写法:throw 一个 Promise,React 自动处理 loading 和错误状态。useTransition 和 useDeferredValue 让你能区分紧急和非紧急更新。
并发模式也带来了新的问题,如竞态条件,需要用 AbortController 或专门的库(如 React Query)来处理。