第 19 课:Streaming 深入 — HTTP 流式渲染的完整机制

0 阅读4分钟

模块四:渲染与性能 | 前置要求:第 10 课 覆盖文档:Streaming 时长:120 分钟


一、传统 SSR vs Streaming

传统 SSR:服务器等待所有数据准备好 → 生成完整 HTML → 一次性发送。一个慢查询阻塞整个页面。

Streaming:服务器先发送准备好的部分(静态外壳)→ 后续内容逐块发送 → 浏览器逐步渲染。用户立即看到内容。


二、两个并行流

2.1 HTML 流

React 服务端渲染器按 <Suspense> 边界生成 HTML 块:

  1. 静态外壳(layouts、导航、Suspense fallbacks)立即发送
  2. 异步组件解析后,流式发送已完成的 HTML
  3. 同时发送内联 <script> 标签:
    • 一个替换 fallback DOM 节点为新内容
    • 一个携带组件载荷用于 Hydration

浏览器立即执行替换脚本——不需要等 JS Bundle 加载或 Hydration 完成。

2.2 组件载荷(RSC Payload)

  • 初次加载:嵌入 HTML 流
  • 客户端导航:单独获取(rsc:1 header),不传 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 正常工作。


十二、关键要点总结

  1. Streaming = 逐块发送,用户立即看到静态外壳
  2. 两个流:HTML 流 + RSC Payload
  3. loading.js = 页面级<Suspense> = 组件级(推荐)
  4. 动态数据下推:不在 layout 中 await,传 Promise 到子组件
  5. HTTP 契约:流式后无法改状态码
  6. notFound/redirect 放在 Suspense 前获取真正 HTTP 状态码
  7. 基础设施:Nginx 禁 buffering,CDN 检查 chunked 支持
  8. Web Vitals:TTFB↓ FCP↓,LCP 放 Suspense 外

下一课:第 20 课:渲染哲学与 PPR