Notes

Streaming SSR + Suspense:提升前端感知性能的利器

一句话结论:Streaming SSR 结合 Suspense,允许服务器在数据准备就绪时,分块、流式地将 HTML 发送给客户端,客户端也能同步进行渐进式 Hydration,从而显著降低 TTFB 和 TTI,极大提升用户感知的页面加载性能。


1. 机制拆解 (How it Works)

传统的 SSR 是一个“瀑布模型”:服务器必须在获取完所有页面数据后,才开始渲染完整的 HTML,然后一次性发送给浏览器。如果某个数据请求缓慢,整个页面都会被阻塞。

Streaming SSR 打破了这一模式,将渲染和传输过程变为“流水线作业”。

核心机制:两个“流”

  1. 服务器渲染流 (Server Rendering Stream)
    • 初始 Shell 发送:服务器首先发送页面的“骨架” (Shell),包含立即可用的 HTML 结构、CSS 和非 Suspense 包装的同步内容。这个 Shell 自身就是一个完整的 HTML 文档。
    • Fallback 占位:对于被 <Suspense> 包裹的异步组件,服务器会在其位置渲染并发送 fallback UI(例如一个加载指示器)。
    • 数据就绪,即刻推流:一旦某个异步组件的数据在服务器上准备就绪(例如,API 请求完成),React 会立刻在服务器上渲染该组件,并通过同一个 HTTP 连接,将渲染好的 HTML 片段“推送”给客户端。
    • 内联脚本注入:推送的 HTML 片段旁边会附带一个 <script> 标签,其中包含了用于将这段 HTML 插入到正确位置的 JavaScript 代码。
  2. 客户端 Hydration 流 (Client Hydration Stream)
    • 同步 Hydration:浏览器收到初始 Shell 后,会像传统 SSR 一样开始对可见部分进行 Hydration,使其具备交互性。
    • 选择性 Hydration:当新的 HTML 片段从服务器流式传输过来时,客户端 React 会利用附带的 <script> 将其“填入”到 fallback 所在的位置。
    • 并发 Hydration:重要的是,React 18 会优先 Hydrate 用户正在交互的区域。例如,即使用户在页面其他部分还在加载时点击了某个按钮,React 也会优先处理该按钮所在组件的 Hydration,从而提供即时响应。这被称为“选择性 Hydration”。

图解:传统 SSR vs Streaming SSR

阶段传统 SSRStreaming SSR with Suspense
服务器1. 等待所有数据1. 发送 HTML Shell + Suspense Fallback
2. 渲染完整 HTML2. (await Promise) 等待数据
3. 一次性发送3. 数据就绪后,渲染组件,推送到流
客户端1. 下载完整 HTML1. 接收 Shell,渲染 Fallback
2. 下载所有 JS2. 下载主要 JS Bundle
3. Hydrate 整个页面3. Hydrate Shell 部分
4. 接收到 HTML 片段,替换 Fallback
5. Hydrate 新到的组件

2. 常见误区/追问 (FAQ)

Q1: Streaming SSR 和客户端渲染 (CSR) 的 Suspense 有何不同? A: 目标不同。CSR 的 Suspense 主要用于处理客户端的数据获取和代码分割时的加载状态。而 Streaming SSR 的 Suspense 则是为了让服务器能够“边获取数据边渲染”,将加载状态的等待从客户端提前到服务器,并通过流式传输隐藏网络延迟。

Q2: 如果我有多个 Suspense 边界,它们是并行加载的吗? A: 是的。在服务器上,React 会 Promise.all 所有并行的数据请求。一旦任何一个请求完成,对应的 HTML 块就会被流式发送。这避免了请求串行化导致的阻塞。

Q3: 这是否意味着我不再需要 useEffect 来获取数据了? A: 在全栈框架(如 Next.js)中,是的。数据获取的逻辑会从 useEffect “上移”到服务器组件或专门的数据加载函数中(如 Next.js 的 loader 或 React Server Components)。这使得数据获取与渲染更紧密地结合,是实现 Streaming SSR 的前提。

Q4: 对 SEO 有什么影响? A: 正面影响。初始 Shell 包含了关键的同步内容,对 SEO 友好。随后流式传输的内容虽然对首次爬取可能不可见,但由于整体 TTFB 和 TTI 改善,搜索引擎可能会给予更高的性能评分。搜索引擎爬虫也在逐步进化以更好地支持这类流式渲染模式。


3. 落地方案 (Implementation)

直接在纯 React 中实现 Streaming SSR 相对复杂,需要手动配置服务器。因此,推荐的落地方案是使用支持此功能的现代全栈框架。

A. Next.js (App Router)

Next.js App Router 是 Streaming SSR 的最佳实践范例。

  • 默认启用:在 App Router 中,流式渲染是默认行为。
  • loading.js 文件:这是一个基于文件系统的约定。在任何目录下创建一个 loading.js 文件,它会自动成为该路由段落的 <Suspense fallback={...}>
  • 数据获取:在页面组件或其子组件中直接使用 async/await 获取数据。Next.js 会自动处理数据依赖,并在服务器上等待数据时,渲染 loading.js 作为 Fallback。
// app/dashboard/page.js
import { Suspense } from 'react';
import { UserProfile } from './UserProfile';
import { TeamList } from './TeamList';
import { StatsSkeleton, TeamSkeleton } from './Skeletons';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <UserProfile />
      </sSuspense>
      <Suspense fallback={<TeamSkeleton />}>
        <TeamList />
      </Suspense>
    </div>
  );
}

// app/dashboard/UserProfile.js
async function UserProfile() {
  const user = await fetchUserData(); // 模拟数据获取
  return <div>{user.name}</div>;
}

// app/dashboard/loading.js (或者使用更细粒度的 Suspense)
export default function Loading() {
  return <p>Loading dashboard...</p>;
}

B. TanStack Router + Vite (手动配置)

虽然 TanStack Router 主要关注客户端,但结合 Vite 的 SSR API,也可以手动实现流式渲染。这需要更多的底层配置。

  1. 服务器入口 (entry-server.jsx):使用 React 的 renderToPipeableStream API。
  2. 配置 Express/Node.js 服务器:创建一个服务器,导入 Vite 编译后的服务器入口,并将其输出 pipe 到 HTTP 响应流中。
  3. 数据加载:利用 TanStack Router 的 loader 函数进行服务器端数据获取,并将数据传递给组件。

这套方案更灵活,但对工程能力要求更高。


4. 观测与验证 (Observability)

如何确认 Streaming SSR 正在按预期工作?

  1. 浏览器网络面板 (Network Panel)
    • 检查主文档请求的 Timing 选项卡。你会看到一个很长的“Content Download”时间,这正是流式传输的特征。
    • 在 Response 选项卡中,你会看到 HTML 并不是一次性加载完成,而是分块到达。
  2. WebPageTest 或 Lighthouse
    • 观察“First Paint”和“Time to First Byte (TTFB)”指标。由于 Shell 的快速发送,这些指标会非常理想。
    • 对比“Time to Interactive (TTI)”,它也应该比传统 SSR 更快,因为 Hydration 是渐进式的。
  3. 禁用 JavaScript 验证
    • 在浏览器中禁用 JavaScript 并重新加载页面。你应该能看到 Fallback UI(例如骨架屏)。这证明了服务器正确地发送了初始的占位符。

5. 关联阅读

cd ..