陌上人如玉
公子世无双

8.4 Suspense 与异步边界

8.4 Suspense 与异步边界

一句话:Suspense 的语义是「在等待某件事完成时显示 fallback」——目前最常用于「懒加载(lazy)」,同时是「并发渲染 + 流式 SSR + React 19 的 use」的基础设施。

一、Suspense 的语义

import { Suspense } from 'react';

<Suspense fallback={<Skeleton />}>
  <Child />                      {/* 子树「挂起」时显示 fallback */}
</Suspense>
「挂起(suspend)」是什么意思?
   子组件在渲染过程中「抛出一个 Promise」→ React 捕获它 →
   ① 在「最近的 Suspense 边界」渲染 fallback
   ② 等 Promise resolve 后「重试渲染」子树
   ③ 成功后显示真正的内容

⭐ 所以 Suspense 是「声明式的等待」:
   你不需要自己写 `if (loading) return <Skeleton />`,
   只要让「数据层」在未就绪时抛 Promise,边界就会自动接管。
它解决的三个问题:
① 懒加载(React.lazy):代码还没下载完时显示 fallback
② 数据获取:数据还没就绪时显示 fallback(需要「支持 Suspense 的数据层」)
③ 流式 SSR:慢的部分后到,先给用户看到已就绪的内容

二、与 React.lazy 配合(最常见的用法)

import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <HeavyChart />
    </Suspense>
  );
}
⚠️ 三个细节:
① `React.lazy` 必须配 `Suspense`(否则报错)
② `fallback` 要「有高度」(否则内容出现时布局跳动 CLS)
③ Suspense 的「位置」决定了「等待期间影响的范围」——
   放在最外层 → 整页骨架;放在内容区 → 只有内容区骨架(见 6.8)
// ✅ 更细的粒度:多个独立区域各自 Suspense
function Dashboard() {
  return (
    <div>
      <Suspense fallback={<KpiSkeleton />}>
        <KpiCards />               {/* 各自加载、各自占位 */}
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart />
      </Suspense>
    </div>
  );
}
// ⭐ 好处:KpiCards 先就绪就先显示(不必等图表)

三、与数据获取配合

// ① 数据路由(React Router):loader 让数据「渲染前」就绪
// 这种方式【不需要 Suspense】——数据已就位才渲染
const router = createBrowserRouter([
  { path: '/users', element: <Users />, loader: () => fetchUsers() },
]);
// ② TanStack Query 的 Suspense 模式
import { useSuspenseQuery } from '@tanstack/react-query';

function Comments({ postId }: { postId: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['comments', postId],
    queryFn: () => fetchComments(postId),
  });
  // ⭐ 没有 isLoading 分支——未就绪时「挂起」,由外层 Suspense 接管
  return <ul>{data.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}

function Page({ postId }: { postId: string }) {
  return (
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments postId={postId} />
    </Suspense>
  );
}
// ③ React 19 的 use():直接读 Promise
import { use, Suspense } from 'react';

function Comments({ promise }: { promise: Promise<Comment[]> }) {
  const comments = use(promise);               // ⭐ 未就绪时挂起
  return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}

function Page() {
  // ⚠️ Promise 必须「稳定」(不能在渲染中创建,否则每次都新建)
  const [promise] = useState(() => fetchComments());
  // 或由「上层/缓存」提供

  return (
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments promise={promise} />
    </Suspense>
  );
}
方式 是否用 Suspense 说明
路由 loader ❌(数据前置) 渲染前就绪,最彻底
useSuspenseQuery ✅ Query 的 Suspense 集成
React 19 use(promise) ✅ 直接读 Promise
手写 useEffect + state ❌(自己管 loading) 传统方式

四、边界划分与嵌套

// ✅ 边界要「独立、慢的部分单独放」
function Page() {
  return (
    <>
      <Header />                                 {/* 同步渲染(快) */}

      <Suspense fallback={<ProfileSkeleton />}>
        <Profile userId={userId} />               {/* 中等速度 */}
      </Suspense>

      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={postId} />              {/* 慢(单独边界 → 不阻塞上面的)*/}
      </Suspense>
    </>
  );
}
划分边界的四条原则:
① 【按「独立的数据块」划分】——一个边界只负责一块「可独立就绪」的内容
② 【慢的单独放】——否则它会拖住同一边界里的其它内容
③ 【fallback 要有合适高度】——避免内容出现时布局跳动(CLS)
④ 【嵌套要有意义】——内层边界可以「先显示部分内容」
// ✅ 嵌套 Suspense 的典型场景:外层先显示骨架,内层再细化
<Suspense fallback={<PageSkeleton />}>
  <PageLayout>
    <Suspense fallback={<SidebarSkeleton />}>
      <Sidebar />
    </Suspense>
    <Suspense fallback={<ContentSkeleton />}>
      <Content />
    </Suspense>
  </PageLayout>
</Suspense>
⚠️ 嵌套的规则:
① 内层挂起时「只显示内层的 fallback」(不冒泡到外层)
② 所以「外层已经有内容」时,内层挂起不会让整页回到骨架
③ 反之如果「没有内层边界」,挂起会冒泡到外层 → 整个外层都显示 fallback

五、避免「闪 loading」

// ❌ 问题:切换 id 时「立刻」显示 fallback(内容闪一下骨架)
function Detail({ id }: { id: string }) {
  return (
    <Suspense fallback={<Skeleton />}>
      <Content id={id} />
    </Suspense>
  );
}
// 从 /1 切到 /2:Content 挂起 → 立刻变成 Skeleton → 数据到了再变回内容
// ✅ 解法一:useDeferredValue 延迟「使用新 id」,让旧内容多留一会儿
function Detail({ id }: { id: string }) {
  const deferredId = useDeferredValue(id);

  return (
    <Suspense fallback={<Skeleton />}>
      <Content id={deferredId} />        {/* ⭐ 先用旧 id 的内容顶着 */}
    </Suspense>
  );
}
// ✅ 解法二:TanStack Query 的 placeholderData(保留上一份数据)
const { data, isPlaceholderData } = useQuery({
  queryKey: ['detail', id],
  queryFn: () => fetchDetail(id),
  placeholderData: (prev) => prev,       // ⭐ 参数变化时保留上一份
});

return (
  <div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>{/* 内容 */}</div>
);
// ✅ 解法三:数据路由下用 useNavigation 显示「顶部进度条」(不清空内容)
function Layout() {
  const navigation = useNavigation();
  return (
    <>
      {navigation.state === 'loading' && <TopProgressBar />}
      <Outlet />
    </>
  );
}

六、错误处理

// ⚠️ Suspense 只处理「等待」,【不处理错误】
// 数据加载失败时应该抛出错误 → 由 ErrorBoundary 接管

// ✅ 正确的组合:ErrorBoundary 在外、Suspense 在内
function Page() {
  return (
    <ErrorBoundary fallback={<ErrorView />}>
      <Suspense fallback={<Skeleton />}>
        <Content />
      </Suspense>
    </ErrorBoundary>
  );
}
⚠️ 顺序为什么是「ErrorBoundary 在外、Suspense 在内」?
① 如果「加载失败」,错误要从内层冒泡到 ErrorBoundary
② 如果「ErrorBoundary 在内」,它的 fallback(错误 UI)会被外层的 Suspense 处理
   → 语义混乱(错误被当成「正在加载」)
③ 所以标准写法是「外层 ErrorBoundary 兜错、内层 Suspense 兜等待」
// ✅ 「加载失败 + 重试」的完整写法
function LazySection({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary
      fallback={({ resetErrorBoundary }) => (
        <div role="alert">
          <p>内容加载失败</p>
          <button onClick={resetErrorBoundary}>重试</button>
        </div>
      )}
    >
      <Suspense fallback={<Skeleton height={300} />}>{children}</Suspense>
    </ErrorBoundary>
  );
}

七、SSR 中的 Suspense(流式渲染)

// ✅ React 18+ 的流式 SSR:shell 先发送,慢的部分后补
// 服务端
import { renderToPipeableStream } from 'react-dom/server';

renderToPipeableStream(
  <App />,
  {
    onShellReady() {
      res.setHeader('content-type', 'text/html');
      pipe(res);                       // ⭐ 外壳就绪就发送
    },
    onError(err) {
      console.error(err);               // 服务端要记录错误并决定是否隐藏
    },
  }
);

// 应用里用 Suspense 划分「可后到的部分」
function App() {
  return (
    <html>
      <body>
        <Header />
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />                  {/* ⭐ 慢的部分:服务端先给占位,数据就绪后流式补上 */}
        </Suspense>
      </body>
    </html>
  );
}
流式 SSR 的三步:
① 服务端渲染「不挂起的部分」(shell)→ 立刻发送
② 渲染到挂起的位置时,输出「fallback 的 HTML」+ 一段「后续会替换它」的脚本
③ 数据就绪后,把「真正的 HTML」+ 替换脚本流式发送 → 客户端无缝替换

⭐ 收益:TTFB 更早、首屏更快可见、慢的部分不阻塞整体
⚠️ 前提:用 renderToPipeableStream(而不是 renderToString)
// ✅ 客户端水合也能「选择性」进行(Selective Hydration)
// React 18+ 会自动:用户点击某个还没水合的区域时,优先水合它
// (需要框架/Suspense 边界的配合,业务代码通常不用手动处理)

八、常见坑

// ① React.lazy 没配 Suspense(报错)
<LazyComp />                                  // ❌

// ② fallback 没有高度(内容出现时布局跳动)
<Suspense fallback={null}>                    // ⚠️
<Suspense fallback={<Skeleton height={300} />}>   // ✅

// ③ 把 Suspense 放在最外层(路由切换时整页闪骨架)
// ④ 边界划分太粗(一个慢请求拖住整块内容)
// ⑤ 边界划分太细(到处都是骨架,视觉噪音大)
// ⑥ 切换参数时「立刻显示 fallback」(应该用 useDeferredValue / placeholderData)
// ⑦ 用 Suspense 处理「错误」(它只管等待,错误要 ErrorBoundary)
// ⑧ 把 ErrorBoundary 放在 Suspense 内层(语义混乱)
// ⑨ 在渲染中创建 Promise 传给 use()(每次渲染都新建 → 无限挂起)
const data = use(fetchData());                // ❌
// ⑩ 认为「Suspense 会自动帮你取数据」(它只是「等待」,取数据要数据层配合)
// ⑪ 在「非并发模式」(legacy ReactDOM.render)下期待新的 Suspense 行为
// ⑫ 用 Suspense 包「同步组件」并期待它挂起(不会)
// ⑬ 忘记「SSR 需要 renderToPipeableStream」(用旧 API 得不到流式收益)
// ⑭ 在「已挂起」的边界里放「必须立即显示的关键内容」
// ⑮ 嵌套 Suspense 却没意识到「内层 fallback 会替换掉外层的部分内容」
// ⑯ 认为「Suspense 能替代 loading 状态管理」(数据路由/Query 的方案各不同)
// ⑨ 的正确写法
function Page() {
  // 方式一:用 useState 惰性初始化(只创建一次)
  const [promise] = useState(() => fetchComments());

  // 方式二:由「缓存/上层」提供(推荐——避免重复请求)
  // 方式三:用框架/RSC 的能力(Next.js 里在 Server Component 里 await)
  return <Suspense fallback={<Skeleton />}><Comments promise={promise} /></Suspense>;
}

九、面试延伸

  1. 「Suspense 是做什么的?」
    Suspense 的语义是「在等待某件事完成时显示 fallback」——它的机制是:子组件在渲染过程中「抛出一个 Promise」时,React 会捕获它,在最近的 Suspense 边界渲染 fallback,等 Promise resolve 后「重试渲染」子树。它主要解决三类问题:① 懒加载(React.lazy);② 数据获取(需要支持 Suspense 的数据层,如 useSuspenseQuery、React 19 的 use);③ 流式 SSR(慢的部分后到)。

  2. 「Suspense 和 loading 状态有什么区别?」
    ① 声明式 vs 命令式——Suspense 是「声明式的等待」(数据层负责 throw Promise,边界负责显示 fallback),不需要自己写 if (loading);② 粒度——Suspense 是「边界级」的(一个边界内的多个组件共享一个 fallback),而手动 loading 通常是「组件级」的;③ 与并发/流式渲染集成——Suspense 是 React 内部机制的一部分(支持「不阻塞已就绪内容」「流式 SSR」「选择性水合」),手动 loading 无法获得这些能力。

  3. 「Suspense 能帮你取数据吗?」
    不能。它只是「等待的容器」——它负责「挂起时显示什么」,不负责「怎么取数据、怎么缓存、怎么重试」。数据获取需要「支持 Suspense 的数据层」:① React Router 的 loader(渲染前取数据,其实不需要 Suspense);② TanStack Query 的 useSuspenseQuery(缓存 + 重试 + 挂起);③ React 19 的 use(promise)(配合「稳定的 Promise」,如由 Server Component 或缓存提供);④ 框架能力(Next.js 的 Server Components 里直接 await)。

  4. 「Suspense 的边界应该放在哪里?」
    四条原则:① 按「独立的数据块」划分——一个边界只负责「可独立就绪」的一块内容;② 慢的单独放——否则它会「挂起整个边界」,拖住同一块里的其它内容;③ fallback 要有确定高度(避免内容出现时的布局跳动 CLS);④ 不要放在最外层(会让「整页显示骨架」,包括导航栏)。典型结构是「页面骨架同步渲染 + 每个数据区块各自一个 Suspense」。

  5. 「怎么避免 Suspense 导致的『闪 loading』?」
    三种方式:① useDeferredValue——延迟「使用新参数」,让旧内容多顶一会儿(<Content id={deferredId} />);② TanStack Query 的 placeholderData——参数变化时保留上一份数据(配合「半透明」表达正在更新);③ 数据路由的 useNavigation——显示「顶部进度条」而不清空内容。核心思路是「有旧数据就先显示旧数据」,而不是「立刻切到 fallback 再切回来」。

  6. 「Suspense 和 ErrorBoundary 应该怎么组合?」
    标准顺序是「ErrorBoundary 在外、Suspense 在内」:

    <ErrorBoundary fallback={<ErrorView />}>
     <Suspense fallback={<Skeleton />}>
       <Content />
     </Suspense>
    </ErrorBoundary>
    

    理由:① 失败时错误要从内层冒泡到 ErrorBoundary(它负责显示错误 UI);② 等待时由内层 Suspense 负责显示骨架。如果反过来(Suspense 在外),那么 ErrorBoundary 的「错误 UI」会被当成「正在加载的内容」由外层 Suspense 处理,语义混乱且可能一直显示 fallback。

  7. 「流式 SSR 是怎么回事?」
    流式 SSR(Streaming SSR)指「服务端边渲染边把 HTML 发给浏览器」:① 先把「不挂起的部分(shell)」渲染完并立即发送(TTFB 更早);② 遇到 Suspense 边界里「慢的部分」时,先发送它的 fallback HTML + 一段脚本;③ 数据就绪后,把「真正的内容 HTML」+ 替换脚本流式发送,浏览器无缝替换。实现上要用 renderToPipeableStream(替代 renderToString),配合 <Suspense> 边界划分。它的收益是「首屏更快可见 + 慢的部分不阻塞整体 + 可交互时间更早」。

  8. 「为什么 use(promise) 里的 Promise 必须稳定?」
    因为 use 会把「这个 Promise」作为「挂起的依据」——如果它在每次渲染时都被重新创建(如 use(fetchData())),React 就会「每次都拿到一个新的未完成 Promise」,于是永远挂起(表现为无限 loading)。所以 Promise 必须由「稳定的来源」提供:① 用 useState(() => fetchData()) 惰性初始化(只创建一次);② 由「上层组件/缓存」提供;③ 由框架(RSC / Server Component)提供。这也是「Suspense 数据获取需要用专门数据层」的原因之一。

一句话速记

Suspense = 「等待某件事完成时显示 fallback」(子组件 throw Promise → 边界接管 → resolve 后重试渲染);主要用途是「React.lazy 懒加载」「支持 Suspense 的数据层(useSuspenseQuery、React 19 use)」「流式 SSR」;它不负责取数据(只负责等待),也不处理错误——所以标准组合是「ErrorBoundary 在外、Suspense 在内」;边界按「独立数据块」划分、慢的单独放、fallback 要有高度、别放最外层;避免闪 loading 用 useDeferredValue / placeholderData / useNavigation 顶部进度条(有旧数据就先显示旧数据);use(promise) 的 Promise 必须稳定(否则无限挂起)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.4 Suspense 与异步边界

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫