模块四:渲染与性能 | 前置要求:第 10 课 覆盖文档:Streaming 时长:120 分钟
一、传统 SSR vs Streaming
传统 SSR:服务器等待所有数据准备好 → 生成完整 HTML → 一次性发送。一个慢查询阻塞整个页面。
Streaming:服务器先发送准备好的部分(静态外壳)→ 后续内容逐块发送 → 浏览器逐步渲染。用户立即看到内容。
二、两个并行流
2.1 HTML 流
React 服务端渲染器按 <Suspense> 边界生成 HTML 块:
- 静态外壳(layouts、导航、Suspense fallbacks)立即发送
- 异步组件解析后,流式发送已完成的 HTML
- 同时发送内联
<script>标签:- 一个替换 fallback DOM 节点为新内容
- 一个携带组件载荷用于 Hydration
浏览器立即执行替换脚本——不需要等 JS Bundle 加载或 Hydration 完成。
2.2 组件载荷(RSC Payload)
- 初次加载:嵌入 HTML 流
- 客户端导航:单独获取(
rsc:1header),不传 HTML - 序列化的组件树表示,React 用它更新客户端
三、静态外壳
Suspense 边界之前的所有内容构成静态外壳:
export default function Dashboard() {
return (
<div>
{/* ─── 静态外壳(立即发送)─── */}
<h1>Dashboard</h1>
<nav>首页 | 分析 | 设置</nav>
{/* ─── 动态内容(各自独立流式加载)─── */}
<Suspense fallback={<p>加载收入...</p>}>
<Revenue /> {/* 200ms 后到达 */}
</Suspense>
<Suspense fallback={<p>加载订单...</p>}>
<RecentOrders /> {/* 1s 后到达 */}
</Suspense>
<Suspense fallback={<p>加载推荐...</p>}>
<Recommendations /> {/* 3s 后到达 */}
</Suspense>
</div>
)
}
Cache Components 下:静态外壳在构建时预渲染,从 CDN 边缘立即交付。
四、页面级 vs 组件级 Streaming
4.1 loading.js(页面级)
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}
原理:loading.js 嵌套在 layout.js 内,自动包裹 page.js 在 <Suspense> 中。
4.2 <Suspense>(组件级,推荐)
// 更精细的控制
<div>
<h1>Dashboard</h1> {/* 立即显示 */}
<Suspense fallback={<ChartSkeleton />}>
<Chart /> {/* 独立加载 */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<Table /> {/* 独立加载 */}
</Suspense>
</div>
4.3 对比
| loading.js | <Suspense> | |
|---|---|---|
| 范围 | 整个页面 | 任意组件 |
| 预取 | 作为即时 fallback 预取 | 不预取 |
| 设置 | 放文件即可 | 需要包裹组件 |
| 推荐 | 页面无部分可展示时 | 大多数情况 |
五、关键原则:动态数据访问下推
将 await 下推到真正需要数据的组件,最大化静态外壳:
// ❌ 不好:layout 中 await → 整个布局被阻塞
export default async function Layout({ children }) {
const cookieStore = await cookies() // 阻塞整个布局!
return (
<div>
<Nav>
<UserMenu theme={cookieStore.get('theme')?.value} />
</Nav>
{children}
</div>
)
}
// ✅ 好:传 Promise 下去,只有 UserMenu 被阻塞
export default function Layout({ children }) {
const cookieStore = cookies() // 不 await!
return (
<div>
<Nav>
<Suspense fallback={<p>加载用户...</p>}>
<UserMenu cookiePromise={cookieStore} />
</Suspense>
</Nav>
{children} {/* 不受影响,立即渲染 */}
</div>
)
}
六、HTTP 契约
6.1 状态码锁定
流式开始后,HTTP 状态码(200 OK)已发送,无法更改。
notFound()中流 → 注入<meta name="robots" content="noindex">(告诉搜索引擎不要索引)redirect()中流 → 变成客户端重定向(不是 HTTP 302)
最佳实践:在任何 <Suspense> 或 await 之前调用 notFound()/redirect(),这样可以获得真正的 HTTP 状态码。
export default async function PostPage({ params }: Props) {
const { slug } = await params
const exists = await checkSlugExists(slug) // 快速检查
if (!exists) notFound() // 真正的 404 状态码
return (
<Suspense fallback={<p>加载文章...</p>}>
<PostContent slug={slug} /> {/* 慢操作放在 Suspense 内 */}
</Suspense>
)
}
6.2 Metadata 与 Bot
- 浏览器:metadata 流式传输,不阻塞 UI
- Bot(Twitterbot、Slackbot):阻塞等待 metadata 在
<head>中 - 可用
htmlLimitedBots自定义
七、基础设施影响
7.1 Nginx
必须禁用 response buffering:
// next.config.ts
async headers() {
return [{
source: '/:path*',
headers: [{ key: 'X-Accel-Buffering', value: 'no' }],
}]
}
7.2 CDN
部分 CDN 默认缓冲整个响应。需要检查是否支持 chunked transfer encoding。
7.3 Serverless
AWS Lambda 需要显式启用 response streaming(非默认)。Vercel 原生支持。
7.4 压缩
Gzip/Brotli 可能缓冲块。检查压缩层是否足够积极地 flush。
7.5 Safari
Safari/WebKit 缓冲前 1024 字节。实际应用(layouts + scripts)很容易超过。
八、Web Vitals 影响
| 指标 | Streaming 影响 |
|---|---|
| TTFB | ↓ 降低(立即发送静态外壳) |
| FCP | ↓ 降低(与数据获取解耦) |
| LCP | 保持低:LCP 元素放在 Suspense 外 |
| CLS | 骨架屏匹配最终内容尺寸 → CLS 低 |
| INP | ↓ 选择性 Hydration + Suspense 分段 |
九、验证 Streaming
9.1 Chrome DevTools
Network tab → 选择文档请求 → Timing 面板 → "Content Download" 阶段长 + "TTFB" 早 = 正在 streaming。
9.2 stream-observer 脚本
// stream-observer.mjs
const res = await fetch('http://localhost:3000/dashboard', {
headers: { 'Accept-Encoding': 'identity' },
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
let i = 0
const start = Date.now()
while (true) {
const { done, value } = await reader.read()
if (done) break
console.log(`\nchunk ${i++} (+${Date.now() - start}ms)\n`)
console.log(decoder.decode(value))
}
node stream-observer.mjs
# chunk 0 (+0ms) → 静态外壳
# chunk 1 (+170ms) → 组件载荷
# chunk 2 (+1000ms) → Revenue 组件
# chunk 3 (+3000ms) → Recommendations 组件
十、流式传数据到客户端
SC 中发起 fetch,传未 await 的 Promise 给 CC:
// page.tsx (SC)
export default function Dashboard() {
const statsPromise = getStats() // 不 await
return (
<Suspense fallback={<p>加载统计...</p>}>
<StatsChart dataPromise={statsPromise} />
</Suspense>
)
}
// stats-chart.tsx (CC)
'use client'
import { use } from 'react'
export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
const stats = use(dataPromise)
return <div>收入:{stats.revenue}</div>
}
十一、课后练习
练习 1:并行 Suspense(基础)
Dashboard 3 个组件各用 Suspense 包裹,模拟不同加载时间。
练习 2:数据下推(中阶)
将 layout 中的 await 改为传 Promise 到子组件,观察静态外壳变化。
练习 3:验证 Streaming(高阶)
用 stream-observer.mjs 观察分块到达时机。
练习 4:基础设施配置(资深)
配置 Nginx 反向代理 + 禁用 buffering + 验证 Streaming 正常工作。
十二、关键要点总结
- Streaming = 逐块发送,用户立即看到静态外壳
- 两个流:HTML 流 + RSC Payload
- loading.js = 页面级,
<Suspense>= 组件级(推荐) - 动态数据下推:不在 layout 中 await,传 Promise 到子组件
- HTTP 契约:流式后无法改状态码
- notFound/redirect 放在 Suspense 前获取真正 HTTP 状态码
- 基础设施:Nginx 禁 buffering,CDN 检查 chunked 支持
- Web Vitals:TTFB↓ FCP↓,LCP 放 Suspense 外
下一课:第 20 课:渲染哲学与 PPR