Streaming SSR + Suspense:提升前端感知性能的利器
一句话结论:Streaming SSR 结合 Suspense,允许服务器在数据准备就绪时,分块、流式地将 HTML 发送给客户端,客户端也能同步进行渐进式 Hydration,从而显著降低 TTFB 和 TTI,极大提升用户感知的页面加载性能。
1. 机制拆解 (How it Works)
传统的 SSR 是一个“瀑布模型”:服务器必须在获取完所有页面数据后,才开始渲染完整的 HTML,然后一次性发送给浏览器。如果某个数据请求缓慢,整个页面都会被阻塞。
Streaming SSR 打破了这一模式,将渲染和传输过程变为“流水线作业”。
核心机制:两个“流”
- 服务器渲染流 (Server Rendering Stream):
- 初始 Shell 发送:服务器首先发送页面的“骨架” (Shell),包含立即可用的 HTML 结构、CSS 和非 Suspense 包装的同步内容。这个 Shell 自身就是一个完整的 HTML 文档。
- Fallback 占位:对于被
<Suspense>包裹的异步组件,服务器会在其位置渲染并发送fallbackUI(例如一个加载指示器)。 - 数据就绪,即刻推流:一旦某个异步组件的数据在服务器上准备就绪(例如,API 请求完成),React 会立刻在服务器上渲染该组件,并通过同一个 HTTP 连接,将渲染好的 HTML 片段“推送”给客户端。
- 内联脚本注入:推送的 HTML 片段旁边会附带一个
<script>标签,其中包含了用于将这段 HTML 插入到正确位置的 JavaScript 代码。
- 客户端 Hydration 流 (Client Hydration Stream):
- 同步 Hydration:浏览器收到初始 Shell 后,会像传统 SSR 一样开始对可见部分进行 Hydration,使其具备交互性。
- 选择性 Hydration:当新的 HTML 片段从服务器流式传输过来时,客户端 React 会利用附带的
<script>将其“填入”到fallback所在的位置。 - 并发 Hydration:重要的是,React 18 会优先 Hydrate 用户正在交互的区域。例如,即使用户在页面其他部分还在加载时点击了某个按钮,React 也会优先处理该按钮所在组件的 Hydration,从而提供即时响应。这被称为“选择性 Hydration”。
图解:传统 SSR vs Streaming SSR
| 阶段 | 传统 SSR | Streaming SSR with Suspense |
|---|---|---|
| 服务器 | 1. 等待所有数据 | 1. 发送 HTML Shell + Suspense Fallback |
| 2. 渲染完整 HTML | 2. (await Promise) 等待数据 | |
| 3. 一次性发送 | 3. 数据就绪后,渲染组件,推送到流 | |
| 客户端 | 1. 下载完整 HTML | 1. 接收 Shell,渲染 Fallback |
| 2. 下载所有 JS | 2. 下载主要 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,也可以手动实现流式渲染。这需要更多的底层配置。
- 服务器入口 (
entry-server.jsx):使用 React 的renderToPipeableStreamAPI。 - 配置 Express/Node.js 服务器:创建一个服务器,导入 Vite 编译后的服务器入口,并将其输出
pipe到 HTTP 响应流中。 - 数据加载:利用 TanStack Router 的
loader函数进行服务器端数据获取,并将数据传递给组件。
这套方案更灵活,但对工程能力要求更高。
4. 观测与验证 (Observability)
如何确认 Streaming SSR 正在按预期工作?
- 浏览器网络面板 (Network Panel):
- 检查主文档请求的 Timing 选项卡。你会看到一个很长的“Content Download”时间,这正是流式传输的特征。
- 在 Response 选项卡中,你会看到 HTML 并不是一次性加载完成,而是分块到达。
- WebPageTest 或 Lighthouse:
- 观察“First Paint”和“Time to First Byte (TTFB)”指标。由于 Shell 的快速发送,这些指标会非常理想。
- 对比“Time to Interactive (TTI)”,它也应该比传统 SSR 更快,因为 Hydration 是渐进式的。
- 禁用 JavaScript 验证:
- 在浏览器中禁用 JavaScript 并重新加载页面。你应该能看到 Fallback UI(例如骨架屏)。这证明了服务器正确地发送了初始的占位符。
5. 关联阅读
- React 18 官方公告: New Suspense SSR Architecture in React 18
- Next.js 文档: Streaming and Suspense
- Hydration Mismatch 排查: (指向
notes/12/01-hydration-mismatch.md的链接) - Suspense 加载态边界设计: (指向
notes/09/04-suspense-loading-boundary-design.md的链接)