Streaming 与 Suspense:渐进式渲染实战
第9章:Streaming 与 Suspense:渐进式渲染实战
传统服务端渲染是阻塞式的——必须完成整个页面渲染才能发送第一个字节。Streaming 让服务器边渲染边发送,Suspense 告诉 React 在哪里切割流。
本章核心问题:HTTP 流式传输如何工作?Suspense 边界如何控制流的切割点?如何避免数据获取的瀑布流?
读完本章你将理解:
- HTTP 分块传输编码(Chunked Transfer Encoding)作为 Streaming 的基础
- 嵌套 Suspense 实现细粒度加载状态与独立并行加载
- use Hook 让客户端组件参与 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>
)
}
渲染流程如下:
- 立即:Next.js 发送 HTML 头部和页面的静态骨架(包括
ChartSkeleton、OrdersSkeleton、ProductsSkeleton的 HTML) - 约 300ms 后:
RecentOrders查询完成,Next.js 通过流发送这部分的真实内容,并附带一小段内联<script>告诉客户端 React 用真实内容替换骨架 - 约 500ms 后:
TopProducts完成,同样流式发送 - 约 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 模型。正确使用这套体系,可以在保持服务器渲染优势的同时,提供媲美原生应用的用户感知性能。