Published on

Suspense:声明式异步加载与并发渲染

在现代 Web 应用中,异步数据请求和代码分割是构建高性能应用的基础。传统上,开发者需要在组件内部手动管理各种加载状态(isLoading, error),导致逻辑分散且容易出错。React Suspense 是 React 框架提供的一种革命性的、声明式的解决方案,它旨在从根本上改变异步操作与 UI 加载状态的管理方式。Suspense 允许开发者将组件的异步依赖(如代码或数据)与其渲染逻辑解耦,并以一种声明式的方式,在组件树中指定加载指示器,从而极大地简化了代码并优化了用户体验。

Suspense 的核心机制

组件签名与属性

Suspense 是一个由 React 提供的内置组件,它接收两个核心属性:

  • fallback: 一个 React 元素(通常是加载指示器,如 Spinner),在 children 尚未准备就绪时被渲染。
  • children: 包含一个或多个可能“悬挂 (suspend)”的组件。
import { Suspense } from 'react';
import MyComponent from './MyComponent';

function App() {
  return (
    <Suspense fallback={<p>Loading component...</p>}>
      <MyComponent />
    </Suspense>
  );
}

“悬挂 (Suspending)” 的原理

核心机制:抛出 Promise

一个子组件向其上层的 <Suspense> 边界发信号,表明自己尚未准备好的方式,是在渲染期间抛出一个 Promise

这并非一个常规的 JavaScript 错误。React 的渲染器会捕获这个被抛出的 Promise。一旦捕获到,React 会暂停该组件的渲染,并向上遍历组件树,寻找最近的 <Suspense> 祖先,然后渲染其 fallback。当这个 Promise 被 resolve 后,React 会再次尝试渲染该组件。

激活 Suspense 的数据源

并非所有异步操作都能自动激活 Suspense。只有那些与 React 的并发渲染机制集成的悬挂式数据源 (Suspense-enabled data sources) 才能触发它。

  • React.lazy(): 这是用于代码分割 (code splitting) 的官方 API。lazy 接收一个返回动态 import() 的函数,并返回一个能够自动在需要时加载代码、并在加载期间“悬挂”的组件。
  • use(Promise): 这是一个较新的 Hook,可以直接在组件中“读取”一个 Promise 的值。在 Promise 处于 pending 状态时,use Hook 会抛出这个 Promise 来激活 Suspense。
  • 集成了 Suspense 的数据请求库: 现代数据请求框架(如 Relay, Next. Js App Router 的 fetch)已经原生支持 Suspense。对于 TanStack Query 等库,也提供了实验性的 Suspense 支持。

实践模式

渐进式加载 (Progressive Loading)

Suspense 组件可以任意嵌套,这使得我们可以构建出渐进式编排式 (orchestrated) 的加载序列,从而优化用户感知性能。

机制: 通过将 UI 划分为多个嵌套的 <Suspense> 边界,可以控制不同部分的加载顺序和 fallback 的显示。外层的 Suspense 会首先展示,当其子组件加载完成后,会显示该子组件的内容,同时触发其内部嵌套的 <Suspense> 开始展示其 fallback

避免不期望的 Fallback

在某些场景下,例如用户在搜索框中输入新内容后触发了新的数据请求,我们不希望页面立即被一个全屏的 fallback 替代,而是期望在后台加载新数据的同时,继续向用户展示旧的、已过时的数据。React 的并发特性为此提供了两个强大的 Hooks。

useTransitionuseDeferredValue

  • useTransition(): const [isPending, startTransition] = useTransition(); 此 Hook 允许你将一个状态更新标记为非紧急的“过渡 (transition)”。当你在 startTransition 的回调中触发一个会引起悬挂的状态更新时,React 会继续显示旧的 UI,直到新的数据加载完成,而不会立即显示 fallbackisPending 布尔值可以用于在旧 UI 上展示一个加载中的提示。
  • useDeferredValue(): const deferredValue = useDeferredValue(value); 此 Hook 允许你“延迟”一个值的更新。它会返回该值的一个“延迟”版本,这个版本会“滞后”于最新的值。你可以用这个延迟的值来渲染旧的 UI,同时在后台用最新的值来渲染新的 UI。
使用 useTransition 避免搜索时的 Fallback

import React, { Suspense, useState, useTransition } from 'react';

// 这是一个假设的、会触发 Suspense 的子组件
function SearchResults({ query }) {
  // 在实际应用中,这里会有一个 use(fetch(...)) 或类似的数据请求
  // 为了演示,我们只返回一个简单的文本
  if (query) {
    return <p>Showing results for "{query}"...</p>;
  }
  return <p>Enter a search term</p>;
}


function SearchPage() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    // 将会触发 Suspense 的状态更新,并将其标记为非紧急的“过渡”
    startTransition(() => {
      setQuery(e.target.value);
    });
  }

  return (
    <div>
      <input 
        onChange={handleChange} 
        value={query}
        placeholder="Search..."
        style={{ marginBottom: '1rem' }}
      />
      
      {/* 当 isPending 为 true (即过渡正在后台发生) 时,
        这个容器会变为半透明,给用户一个视觉提示,
        但其内部仍然显示着旧的 SearchResults 内容。
      */}
      <div style={{ opacity: isPending ? 0.5 : 1 }}>
        <Suspense fallback={<h1>🌀 Loading initial results...</h1>}>
          <SearchResults query={query} />
        </Suspense>
      </div>
    </div>
  );
}

在这个例子中,当用户输入时,SearchResults 会在后台重新获取数据并渲染。在此期间,屏幕上会继续显示旧的搜索结果(并带有半透明效果),而不是 fallback 中的 <h1>🌀 Loading...</h1>