流式渲染 Streaming SSR

传统 SSR 必须等所有数据加载完才能返回 HTML——如果一个 API 慢 2 秒,TTFB 就是 2 秒。流式 SSR(Streaming SSR)打破了这个限制:服务端可以先发送页面骨架,再逐步推送数据填充内容。用户更早看到页面框架,体验显著提升。本章深入解析 Nuxt4 流式渲染的原理、实现方式和最佳实践。

1. 传统 SSR 的瓶颈

1.1 串行等待问题

传统 SSR 的渲染流程是严格串行的:

请求到达 → 执行所有 setup()
         → 等待所有 useFetch/useAsyncData 完成
         → 渲染完整 HTML
         → 发送响应

如果页面有三个独立的数据请求:

请求耗时
视频详情50ms
推荐列表200ms
评论列表800ms

虽然 Nuxt 会并行发送这三个请求(useAsyncData 自动并行),但必须等最慢的请求完成才能发送 HTML:

TTFB = max(50ms, 200ms, 800ms) + 渲染时间 ≈ 850ms

用户在 850ms 后才看到任何内容——但实际上视频详情在 50ms 就准备好了。

1.2 核心矛盾

传统 SSR 的核心矛盾是:快数据被慢数据拖累。页面大部分内容 100ms 内就能准备好,但一个慢接口拖累了整个页面的 TTFB。

2. 流式 SSR 原理

2.1 HTTP 分块传输

流式 SSR 基于 HTTP 的 Chunked Transfer Encoding——服务端不需要一次性发送完整响应,可以分多个 chunk 发送:

服务端 → [chunk 1: HTML 头部 + 页面骨架] → 浏览器开始渲染
       → [chunk 2: 视频详情数据 + HTML]  → 填充视频区域
       → [chunk 3: 推荐列表数据 + HTML]  → 填充推荐区域
       → [chunk 4: 评论列表数据 + HTML]  → 填充评论区域

浏览器收到第一个 chunk 就开始解析和渲染——用户在 100ms 内就能看到页面骨架和视频详情,不需要等 800ms 的评论加载完。

2.2 工作流程

1. 服务端开始渲染,遇到 <Suspense> 边界
2. 先发送 <Suspense> 的 fallback(loading 占位)
3. 异步数据准备好后,发送真实内容替换 fallback
4. 浏览器通过内联 <script> 替换 DOM

关键:流式 SSR 不是简单的"分段发送 HTML",而是利用 Vue 的 &lt;Suspense&gt; 组件作为"分割点"——&lt;Suspense&gt;fallback 先发送,default 等数据好了再发送。

3. Nuxt4 中的流式渲染

3.1 开启方式

Nuxt4 默认支持流式 SSR,无需额外配置。关键在于正确使用 &lt;Suspense&gt;lazy 数据获取:

// nuxt.config.ts
export default defineNuxtConfig({
  // 流式 SSR 默认开启,无需额外配置
  // 但需要确保部署平台支持流式响应(Node.js 服务器、Cloudflare Workers 等)
})

3.2 NuxtPage 内置 Suspense

&lt;NuxtPage&gt; 组件内置了 &lt;Suspense&gt; 包裹。这意味着页面组件中的 await useFetch() 默认就会触发 Suspense 行为。

3.3 手动 Suspense 边界

对于页面内的独立区域,可以手动添加 &lt;Suspense&gt; 边界:

<template>
  <div>
    <!-- 视频详情:快速加载,立即渲染 -->
    <VideoHeader :video="video" />
 
    <!-- 推荐列表:独立 Suspense 边界 -->
    <Suspense>
      <LazyVideoRecommendations :video-id="video.id" />
      <template #fallback>
        <div class="grid grid-cols-4 gap-4">
          <div v-for="i in 4" :key="i" class="aspect-video bg-gray-200 animate-pulse rounded" />
        </div>
      </template>
    </Suspense>
 
    <!-- 评论区:独立 Suspense 边界 -->
    <Suspense>
      <LazyVideoComments :video-id="video.id" />
      <template #fallback>
        <div class="space-y-4">
          <div v-for="i in 3" :key="i" class="h-20 bg-gray-200 animate-pulse rounded" />
        </div>
      </template>
    </Suspense>
  </div>
</template>

每个 &lt;Suspense&gt; 都是一个独立的流式分割点——数据好了就发送,互不阻塞。

4. lazy 模式数据获取

4.1 useFetch 的 lazy 选项

// 默认模式:阻塞渲染,等数据好了再发送 HTML
const { data: video } = await useFetch(`/api/videos/${id}`)
 
// lazy 模式:不阻塞渲染,先发送 loading 状态
const { data: comments, pending } = await useFetch(`/api/videos/${id}/comments`, {
  lazy: true,
})

默认模式 vs lazy 模式的区别:

特性默认模式lazy 模式
SSR 阻塞✅ 等数据好了再渲染❌ 先渲染 pending 状态
首次 HTML 中有数据❌(需要客户端 Hydration 后填充)
TTFB 影响增加 TTFB不增加 TTFB
适用场景关键数据(SEO 需要)非关键数据(评论、推荐)

4.2 混合策略

一个页面中通常混合使用两种模式:

// 视频详情:关键数据,阻塞等待(SEO 需要)
const { data: video } = await useFetch(`/api/videos/${id}`)
 
// 推荐列表:非关键,lazy 加载
const { data: recommendations, pending: recPending } = await useFetch(
  `/api/videos/${id}/recommendations`,
  { lazy: true }
)
 
// 评论列表:非关键,lazy 加载
const { data: comments, pending: commentsPending } = await useFetch(
  `/api/videos/${id}/comments`,
  { lazy: true }
)

这样视频详情立即渲染(SEO 爬虫能看到),推荐和评论异步加载不拖累 TTFB。

4.3 useLazyFetch 语法糖

useLazyFetch 等价于 useFetch(&#123; lazy: true &#125;)

// 这两行等价
const { data } = await useFetch('/api/data', { lazy: true })
const { data } = await useLazyFetch('/api/data')

5. 流式渲染的边界条件

5.1 SEO 考量

搜索引擎爬虫对流式 SSR 的处理:

  • Googlebot:支持流式 HTML,能等待完整内容
  • 其他爬虫:可能只处理第一个 chunk

经验法则:SEO 关键内容(标题、描述、主体内容)放在第一个 chunk 中(不用 lazy),非关键内容(评论、推荐)可以 lazy 加载。

5.2 错误处理

流式响应一旦开始发送,就无法修改 HTTP 状态码。如果第一个 chunk 已经发送了 200,后续数据加载失败只能在 HTML 中处理(不能改成 500):

<Suspense>
  <LazyVideoComments :video-id="video.id" />
  <template #fallback>
    <div class="animate-pulse">加载评论中...</div>
  </template>
</Suspense>
 
<!-- 如果评论加载失败,组件内部处理 -->
<!-- VideoComments.vue -->
<template>
  <div v-if="error" class="text-red-500">
    评论加载失败,<button @click="refresh">重试</button>
  </div>
  <div v-else>{{ data }}</div>
</template>

5.3 Head 管理

流式渲染的 &lt;head&gt; 标签在第一个 chunk 就发送了。后续 chunk 中通过 useHead 设置的 meta 无法插入到已发送的 &lt;head&gt; 中。Nuxt 通过 Payload 机制在客户端 Hydration 时补全。

5.4 部署平台兼容性

平台流式支持
Node.js 服务器
Cloudflare Workers
Vercel Edge
AWS Lambda⚠️ 需要 Response Streaming 配置
静态托管(Netlify Static)❌ 不支持 SSR

6. AI 视频推荐列表流式渲染实战

6.1 场景分析

视频详情页的数据来源:

数据API 耗时重要性策略
视频元信息20ms核心(SEO)阻塞等待
视频播放地址50ms核心阻塞等待
AI 推荐列表500ms辅助lazy + Suspense
评论列表300ms辅助lazy + Suspense
作者信息30ms辅助可以阻塞(很快)

6.2 实现

<!-- pages/videos/[id].vue -->
<template>
  <div class="max-w-[1400px] mx-auto">
    <!-- 第一个 chunk:立即发送 -->
    <VideoPlayer :video="video" />
    <VideoMeta :video="video" :author="author" />
 
    <div class="grid grid-cols-3 gap-8 mt-8">
      <div class="col-span-2">
        <!-- 第三个 chunk:评论准备好后发送 -->
        <Suspense>
          <LazyVideoComments :video-id="video.id" />
          <template #fallback>
            <CommentsSkeleton />
          </template>
        </Suspense>
      </div>
 
      <div>
        <!-- 第二个 chunk:推荐准备好后发送 -->
        <Suspense>
          <LazyVideoRecommendations :video-id="video.id" />
          <template #fallback>
            <RecommendationsSkeleton />
          </template>
        </Suspense>
      </div>
    </div>
  </div>
</template>
 
<script setup>
const route = useRoute()
const id = route.params.id
 
// 关键数据:阻塞等待
const [{ data: video }, { data: author }] = await Promise.all([
  useFetch(`/api/videos/${id}`),
  useFetch(`/api/videos/${id}/author`),
])
 
if (!video.value) throw createError({ statusCode: 404, message: '视频不存在' })
</script>

6.3 时序对比

传统 SSR

0ms                                        850ms
|——————————————————————————————————————————|→ 发送完整 HTML
  等待所有数据...                           TTFB: 850ms

流式 SSR

0ms    70ms                 370ms          570ms
|——————|———————————————————|——————————————|
  骨架    视频+作者            评论           推荐
  TTFB: 70ms(用户立即看到内容)

TTFB 从 850ms 降到 70ms——用户体验提升 12 倍。

7. 流式渲染的权衡

7.1 适合流式渲染

  • 页面有多个独立数据源,耗时差异大
  • 非关键内容可以延迟显示
  • TTFB 是性能瓶颈

7.2 不适合流式渲染

  • 所有数据都很快(< 100ms),流式带来的收益不明显
  • 页面内容相互依赖(B 依赖 A 的数据)
  • 目标平台不支持流式响应(Lambda 无 Streaming)

7.3 与 ISR 的互补

流式 SSR 和 ISR 可以结合使用:

  • ISR 缓存命中:直接返回缓存的完整 HTML(最快)
  • ISR 缓存未命中:触发 SSR,此时流式渲染减少首次 TTFB
// 两者结合
export default defineNuxtConfig({
  routeRules: {
    '/videos/**': { isr: 3600 },  // 缓存 1 小时
    // 缓存未命中时,流式 SSR 自动生效
  },
})

本章小结

  • 核心问题:传统 SSR 中快数据被慢数据拖累,TTFB = max(所有请求)
  • 流式原理:HTTP Chunked Transfer + Vue Suspense 分割点,数据好了就发送
  • 实现方式&lt;Suspense&gt; 包裹 + Lazy 组件 + lazy: true 数据获取
  • 混合策略:SEO 关键数据阻塞等待,辅助内容 lazy 加载
  • 注意事项:状态码在第一个 chunk 确定、Head 在第一个 chunk 发送、部署平台兼容性
  • 与 ISR 互补:ISR 解决重复请求,流式 SSR 解决首次请求的 TTFB