第 9 章

Streaming 与 Suspense:渐进式渲染实战

第9章:Streaming 与 Suspense:渐进式渲染实战

传统服务端渲染是阻塞式的——必须完成整个页面渲染才能发送第一个字节。Streaming 让服务器边渲染边发送,Suspense 告诉 React 在哪里切割流。

本章核心问题:HTTP 流式传输如何工作?Suspense 边界如何控制流的切割点?如何避免数据获取的瀑布流?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

HTTP 分块传输编码:Streaming 的基础

Streaming 能工作,是因为 HTTP/1.1 的分块传输编码(Chunked Transfer Encoding)机制。在普通的 HTTP 响应中,服务器发送响应前必须设置 Content-Length header,告知浏览器响应体的总字节数。但分块传输不需要这个:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html

1a\r\n                          ← 第一块大小(十六进制)
<html><head>...</head>\r\n      ← 第一块内容
...\r\n                         ← 更多块...
0\r\n                           ← 零长度块表示结束
\r\n

浏览器一旦接收到数据块,就可以立即开始解析和渲染,不需要等待整个响应完成。Next.js 利用这一机制,将页面分割成多个块,随着数据准备好就逐块发送。

在 Node.js 中,这对应的是 ReadableStream API。Next.js App Router 内部使用 Web Streams API 来实现流式 HTML 渲染。

Suspense 边界:告诉 Next.js 在哪里切割

React 的 <Suspense> 组件是 Streaming 的控制接口。它告诉 React:"这部分内容可能还没准备好,先用 fallback 占位,等准备好了再替换。"

在 Next.js App Router 中,<Suspense> 边界同时也是 HTML 流的切割点:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from './RevenueChart'
import { RecentOrders } from './RecentOrders'
import { TopProducts } from './TopProducts'
import { ChartSkeleton, OrdersSkeleton, ProductsSkeleton } from './Skeletons'

export default function DashboardPage() {
  return (
    <div className="dashboard">
      <h1>数据总览</h1>

      {/* 每个 Suspense 边界都是一个独立的流式块 */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <div className="grid grid-cols-2">
        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>

        <Suspense fallback={<ProductsSkeleton />}>
          <TopProducts />
        </Suspense>
      </div>
    </div>
  )
}
// app/dashboard/RevenueChart.tsx
import { db } from '@/lib/db'

// 这是一个异步服务器组件,会触发 Suspense
export async function RevenueChart() {
  // 假设这个查询需要 600ms
  const data = await db.order.groupBy({
    by: ['month'],
    _sum: { amount: true },
    orderBy: { month: 'asc' },
  })

  return (
    <div className="chart">
      {/* 渲染图表 */}
      {data.map(d => (
        <div key={d.month} style={{ height: `${d._sum.amount / 100}px` }} />
      ))}
    </div>
  )
}

渲染流程如下:

  1. 立即:Next.js 发送 HTML 头部和页面的静态骨架(包括 ChartSkeletonOrdersSkeletonProductsSkeleton 的 HTML)
  2. 约 300ms 后RecentOrders 查询完成,Next.js 通过流发送这部分的真实内容,并附带一小段内联 <script> 告诉客户端 React 用真实内容替换骨架
  3. 约 500ms 后TopProducts 完成,同样流式发送
  4. 约 600ms 后RevenueChart 完成,发送图表内容

用户在 0ms 就看到了页面结构和骨架屏,各个区块按数据准备速度逐步填充,而不是等最慢的 600ms 后才看到任何内容。

loading.tsx:路由级别的 Suspense 快捷方式

Next.js 提供了一个约定:在路由目录下创建 loading.tsx,它会自动成为该路由的顶层 <Suspense> 边界的 fallback

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="dashboard animate-pulse">
      <div className="h-8 w-48 bg-gray-200 rounded mb-6" />
      <div className="h-64 bg-gray-200 rounded mb-4" />
      <div className="grid grid-cols-2 gap-4">
        <div className="h-48 bg-gray-200 rounded" />
        <div className="h-48 bg-gray-200 rounded" />
      </div>
    </div>
  )
}

loading.tsx 对应的 Suspense 边界包裹整个页面内容,所以它会在页面的任何一个异步操作完成之前展示。更细粒度的骨架屏需要在页面内部手动添加 <Suspense>

Level 2 · 它是怎么运行的(3-5年经验)

骨架屏设计原则

骨架屏(Skeleton)的设计直接影响用户感知性能。好的骨架屏有以下特征:

// components/skeletons/CardSkeleton.tsx
export function CardSkeleton() {
  return (
    // 1. 尺寸与真实内容一致,避免布局偏移(CLS)
    <div className="card h-[240px] w-full">
      {/* 2. 使用 animate-pulse 或 shimmer 动画,表明"正在加载" */}
      <div className="animate-pulse space-y-3 p-4">
        {/* 3. 形状暗示内容类型(宽条 = 标题,窄条 = 正文) */}
        <div className="h-4 bg-gray-200 rounded w-3/4" />
        <div className="h-3 bg-gray-200 rounded w-full" />
        <div className="h-3 bg-gray-200 rounded w-5/6" />
        {/* 4. 底部操作区 */}
        <div className="pt-4 flex gap-2">
          <div className="h-8 bg-gray-200 rounded w-24" />
          <div className="h-8 bg-gray-200 rounded w-16" />
        </div>
      </div>
    </div>
  )
}

CSS shimmer 动画比简单的 pulse 更精致:

/* globals.css */
@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton-shimmer {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

嵌套 Suspense:细粒度加载状态

Suspense 可以嵌套,形成更细粒度的加载控制:

// app/shop/[id]/page.tsx
import { Suspense } from 'react'

export default async function ProductDetailPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  return (
    <div className="product-detail">
      {/* 商品基础信息 —— 快,直接在页面组件中获取 */}
      <ProductHero id={id} />

      <div className="grid grid-cols-3">
        <div className="col-span-2">
          {/* 商品描述 —— 中等速度 */}
          <Suspense fallback={<DescriptionSkeleton />}>
            <ProductDescription id={id} />
          </Suspense>

          {/* 用户评价 —— 较慢(需要聚合计算) */}
          <Suspense fallback={<ReviewsSkeleton />}>
            <ProductReviews id={id} />
          </Suspense>
        </div>

        <aside>
          {/* 价格和库存 —— 实时数据,最慢 */}
          <Suspense fallback={<PriceSkeleton />}>
            <PriceAndStock id={id} />
          </Suspense>

          {/* 推荐商品 —— 最不重要,允许最后加载 */}
          <Suspense fallback={<RecommendationsSkeleton />}>
            <Recommendations id={id} />
          </Suspense>
        </aside>
      </div>
    </div>
  )
}

每个 <Suspense> 边界都独立:ProductDescription 完成时立即显示,不需要等 ProductReviews

Level 3 · 规范怎么定义的(资深)

瀑布流问题与 Promise.all 并行化

Streaming 解决了组件间的串行等待,但如果一个组件内部发出多个顺序请求,瀑布流仍然存在:

// ❌ 瀑布流:三个请求顺序执行,总耗时 = 300 + 500 + 200 = 1000ms
export async function DashboardStats({ userId }: { userId: string }) {
  const user = await getUser(userId)         // 300ms
  const orders = await getOrders(userId)     // 500ms
  const stats = await getStats(userId)       // 200ms

  return <StatsDisplay user={user} orders={orders} stats={stats} />
}
// ✅ 并行:三个请求同时发出,总耗时 = max(300, 500, 200) = 500ms
export async function DashboardStats({ userId }: { userId: string }) {
  const [user, orders, stats] = await Promise.all([
    getUser(userId),
    getOrders(userId),
    getStats(userId),
  ])

  return <StatsDisplay user={user} orders={orders} stats={stats} />
}

更进一步,可以将互不依赖的数据请求拆分到不同组件,配合 Suspense 实现真正的独立并行加载:

// ✅ 最优:各组件独立 Suspense,互不阻塞
export default function Dashboard({ userId }: { userId: string }) {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserInfo userId={userId} />     {/* 内部 await getUser,300ms */}
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <OrderList userId={userId} />    {/* 内部 await getOrders,500ms */}
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel userId={userId} />   {/* 内部 await getStats,200ms */}
      </Suspense>
    </div>
  )
}

三个组件的异步操作在服务器上并发执行,各自独立完成,各自独立 stream 到客户端。总耗时取决于最慢的那个(500ms),但用户在 200ms 就看到了 Stats,300ms 看到了 User,而不是等 500ms 才看到全部。

use Hook:在客户端组件中读取 Promise

有时你需要在客户端组件中处理 Promise(通常由服务器组件传入)。React 19 引入的 use Hook 允许在客户端组件的渲染过程中"暂停"以等待 Promise:

// app/notifications/page.tsx — 服务器组件
import { Suspense } from 'react'
import { NotificationList } from './NotificationList'

export default function NotificationsPage() {
  // 注意:不 await,直接传 Promise 给客户端组件
  const notificationsPromise = fetchNotifications()

  return (
    <Suspense fallback={<div>加载通知中...</div>}>
      <NotificationList notificationsPromise={notificationsPromise} />
    </Suspense>
  )
}
// app/notifications/NotificationList.tsx — 客户端组件
'use client'

import { use } from 'react'

interface Notification {
  id: string
  message: string
  read: boolean
}

export function NotificationList({
  notificationsPromise,
}: {
  notificationsPromise: Promise<Notification[]>
}) {
  // use() 会"暂停"此组件渲染,直到 Promise resolve
  // 配合 Suspense,暂停期间显示 fallback
  const notifications = use(notificationsPromise)

  return (
    <ul>
      {notifications.map(n => (
        <li key={n.id} className={n.read ? 'opacity-50' : ''}>
          {n.message}
        </li>
      ))}
    </ul>
  )
}

use Hook 与 await 的区别:use 在客户端组件中工作,可以在条件语句中使用(不同于其他 Hooks),并且与 Suspense 和错误边界集成。

RSC + Suspense + Streaming 的完整实战示例

将以上所有概念整合到一个真实的数据看板场景:

// app/analytics/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

// 这个页面是动态的(使用了 cookies)
export const dynamic = 'force-dynamic'

export default async function AnalyticsPage() {
  const cookieStore = await cookies()
  const token = cookieStore.get('auth_token')?.value
  if (!token) redirect('/login')

  // 立即发送 HTML 骨架,包含所有 Suspense fallback
  return (
    <div className="analytics-dashboard">
      <header className="dashboard-header">
        <h1>数据分析</h1>
        {/* 快速数据,直接在 header 渲染 */}
        <Suspense fallback={<span className="skeleton-shimmer w-24 h-4" />}>
          <LiveVisitorCount token={token} />
        </Suspense>
      </header>

      <div className="metrics-grid">
        {/* 关键指标:中等速度 */}
        <Suspense fallback={<MetricsSkeleton count={4} />}>
          <KeyMetrics token={token} />
        </Suspense>
      </div>

      <div className="charts-section">
        {/* 趋势图:较慢(聚合查询) */}
        <Suspense fallback={<ChartSkeleton height={300} />}>
          <TrendChart token={token} period="7d" />
        </Suspense>

        {/* 地图:最慢(地理数据聚合) */}
        <Suspense fallback={<MapSkeleton />}>
          <GeoMap token={token} />
        </Suspense>
      </div>

      {/* 明细表格:允许最后加载 */}
      <Suspense fallback={<TableSkeleton rows={10} />}>
        <EventsTable token={token} />
      </Suspense>
    </div>
  )
}
// app/analytics/KeyMetrics.tsx
import { fetchMetrics } from '@/lib/analytics'

export async function KeyMetrics({ token }: { token: string }) {
  // 用 Promise.all 并行获取所有指标
  const [pageViews, sessions, bounceRate, avgDuration] = await Promise.all([
    fetchMetrics(token, 'pageviews'),
    fetchMetrics(token, 'sessions'),
    fetchMetrics(token, 'bounce_rate'),
    fetchMetrics(token, 'avg_duration'),
  ])

  return (
    <div className="grid grid-cols-4 gap-4">
      <MetricCard label="页面浏览" value={pageViews} />
      <MetricCard label="访问会话" value={sessions} />
      <MetricCard label="跳出率" value={`${bounceRate}%`} />
      <MetricCard label="平均时长" value={`${avgDuration}s`} />
    </div>
  )
}

这个设计的效果:页面在 ~0ms 展示完整的骨架结构,各个区块按数据准备速度独立填充,用户始终看到有意义的内容而非空白。

错误边界与 Suspense 的协作

Suspense 处理"加载中"状态,Error Boundary 处理"出错"状态。两者配合,构成完整的异步 UI 管理:

// components/AsyncBoundary.tsx
'use client'

import { Component, ReactNode, Suspense } from 'react'

interface ErrorBoundaryState {
  hasError: boolean
  error?: Error
}

class ErrorBoundary extends Component<
  { children: ReactNode; fallback: ReactNode },
  ErrorBoundaryState
> {
  constructor(props: { children: ReactNode; fallback: ReactNode }) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback
    }
    return this.props.children
  }
}

// 组合 ErrorBoundary + Suspense 的便捷组件
export function AsyncBoundary({
  children,
  loadingFallback,
  errorFallback,
}: {
  children: ReactNode
  loadingFallback: ReactNode
  errorFallback: ReactNode
}) {
  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense fallback={loadingFallback}>
        {children}
      </Suspense>
    </ErrorBoundary>
  )
}

Level 4 · 边界与陷阱(所有人)

陷阱1:Streaming 解决了组件间的串行等待,但如果一个组件内部发出多个顺序请求,瀑布流仍然存在——需要用 Promise.all 并行化。

陷阱2:loading.tsx 是路由级别的 Suspense 快捷方式,更细粒度的骨架屏需要在页面内部手动添加

陷阱3:use Hook 与 await 的区别:use 在客户端组件中工作,可以在条件语句中使用(不同于其他 Hooks)。

小结

Streaming 通过 HTTP 分块传输使服务器能边渲染边发送,Suspense 边界是 React 向 Next.js 传递"在哪里切割流"信号的接口。loading.tsx 是路由级别的 Suspense 快捷方式。避免瀑布流的关键是使用 Promise.all 并行化同一组件内的多个请求,以及将独立数据源拆分到不同组件配合各自的 Suspense。use Hook 让客户端组件也能参与 Suspense 模型。正确使用这套体系,可以在保持服务器渲染优势的同时,提供媲美原生应用的用户感知性能。

本章评分
4.7  / 5  (38 评分)

💬 留言讨论