React 并发特性与 Suspense

第五编 · 第十一章:React 并发特性与 Suspense的深入分析


并发是什么

并发(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 和错误状态。useTransitionuseDeferredValue 让你能区分紧急和非紧急更新。

并发模式也带来了新的问题,如竞态条件,需要用 AbortController 或专门的库(如 React Query)来处理。