TL;DR: Streaming SSR 将传统的同步"全有或全无"服务端渲染流程,转变为一个渐进式流水线——HTML 在可用时即发送给浏览器。通过利用 React 19 的
renderToReadableStream和精心设计的 Suspense 边界,你可以在不牺牲 SEO 的前提下将 Largest Contentful Paint(LCP)改善 30-50%。本文深入解析其内部机制、trade-off,以及如何在 TanStack Start 中实现流式渲染。
Introduction: From SSR to Streaming SSR
Server-side rendering(服务端渲染)长期以来一直是实现快速首屏加载和 SEO 友好内容的行业标准。但传统 SSR 有一个根本性缺陷:它是同步的。服务器必须获取全部数据、渲染完整组件树之后,才能发送完整的 HTML 响应。浏览器只能空等。
Streaming SSR 改变了这一范式。服务器不再等待整个页面渲染完成,而是渐进式地发送 HTML 片段。浏览器可以在完整响应到达之前,就开始解析 HTML、加载资源,甚至绘制部分内容。
How Traditional SSR Works(渲染 → 等待 → 发送)
传统 SSR 流程:
1. 服务器接收请求
2. 服务器获取所有数据(异步瀑布流)
3. 服务器将整个 React 树渲染为 HTML 字符串
4. 服务器发送完整 HTML 响应
5. 浏览器收到 HTML,开始解析
关键问题:每个数据依赖都必须在第一个字节到达浏览器之前解析完毕。如果页脚需要来自慢速 API 的数据,页头和 Hero 区域就只能被拖累。
以下是传统阻塞式 SSR 模式的简化版本:
// 传统 SSR — 所有步骤都是阻塞的
async function renderPage(url) {
const data = await fetchAllData(url); // 阻塞
const html = ReactDOMServer.renderToString(<App data={data} />); // 阻塞
return sendResponse(html); // 终于发送
}
这造成了行首阻塞(head-of-line blocking)。TTFB 变差,因为服务器必须等所有数据就绪才能响应。
How Streaming SSR Works(渲染 → 流式 → 渐进增强)
Streaming SSR 将模型翻转:
1. 服务器接收请求
2. 服务器立即开始渲染
3. 服务器立即输出 HTML 外壳(header、nav、skeleton)
4. 数据解析完成后,服务器流式推送已渲染的组件
5. 浏览器渐进式绘制内容
6. 流式传输全部完成后执行水合
服务器不需要等待所有数据就绪才开始发送 HTML。 页面外壳可以即时发出。随着每个 Suspense 边界的数据解析完成,其渲染好的 HTML 就会被推送下来。
async function renderPage(url, res) {
const stream = await ReactDOMServer.renderToReadableStream(<App />, {
onError(error) {
console.error('流式渲染错误:', error);
}
});
await stream.pipeTo(new WritableStream({
write(chunk) { res.write(chunk); }
}));
res.end();
}
浏览器在服务器仍在渲染深层组件时,几乎即时开始接收 HTML。
以下是使用 React 19 renderToReadableStream 的流式等价实现:
React 19 Streaming Primitives
renderToReadableStream
流式 SSR 的核心是 renderToReadableStream。以下是其在服务器请求处理器中的用法:
import { renderToReadableStream } from 'react-dom/server';
async function handleRequest(req, res) {
const stream = await renderToReadableStream(
<HtmlShell><App /></HtmlShell>,
{
bootstrapScripts: ['/client.js'],
onError(err) {
console.error(err);
},
}
);
await stream.pipeTo(new WritableStream({
write(chunk) { res.write(chunk); }
}));
res.end();
}
流直接 pipe 到响应中,片段一旦就绪即发送。不像 renderToString 需要等待整棵树渲染完毕,这种方式渐进式地发送 HTML。
The Role of Suspense Boundaries
Suspense 边界是解锁流式渲染的关键。每个 <Suspense> 边界都充当独立的渲染单元。以下布局展示了折行上下的内容如何差异化处理:
function Page() {
return (
<Layout>
<Header /> {/* 立即 flushed */}
<Suspense fallback={<Spinner />}>
<ArticleContent /> {/* 数据就绪后 streamed */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* 独立 streamed */}
</Suspense>
<Footer /> {/* 立即 flushed */}
</Layout>
);
}
每个 Suspense 边界都有独立的时序。浏览器逐步将 fallback 占位符替换为真实内容。
TanStack Start Streaming Implementation
TanStack Start 将流式 SSR 作为一等公民来拥抱,仅需一个简单的配置开关即可启用。以下是启用流式的服务器配置:
// app/server.ts
import { createStartHandler } from '@tanstack/start/server';
import { getRouterManifest } from '@tanstack/start/router-manifest';
import { createRouter } from './router';
export default createStartHandler({
createRouter,
getRouterManifest,
})({
streaming: true, // 默认启用
suspense: {
shellTimeout: 5_000,
emitLoadingStates: true,
},
});
Route-Level Data Loading with Streaming
使用 defer 工具处理非关键数据,这些数据可以在初始外壳渲染后再流式传输。以下示例展示了一个博客文章页面:文章内容立即加载,而推荐内容和评论则流式推送进来:
export const Route = createFileRoute('/blog/$slug')({
loader: async ({ params, context }) => {
return {
article: await context.db.getArticle(params.slug),
recommendations: defer(
context.db.getRelatedPosts(params.slug, { limit: 3 })
),
comments: defer(
context.db.getComments(params.slug),
{ timeout: 10_000 }
),
};
},
component: BlogPost,
});
function BlogPost() {
const { article, recommendations, comments } = Route.useLoaderData();
return (
<article>
<h1>{article.title}</h1>
<BlogContent content={article.content} />
<Suspense fallback={<RelatedSkeleton />}>
<Await promise={recommendations}>
{(recs) => <RelatedPosts posts={recs} />}
</Await>
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Await promise={comments}>
{(cmts) => <CommentsSection comments={cmts} />}
</Await>
</Suspense>
</article>
);
}
); }
`defer` 工具告诉路由器:"立即渲染所有能渲染的,数据就绪时再流式推送。"`<Await>` 组件是一个支持 Suspense 的包装器,在延迟的 promise 解析之前渲染 fallback。
## Suspense Strategies
### Rule 1: The Shell Boundary
将折行以下的所有内容包裹在至少一个 Suspense 边界中,确保页面外壳立即渲染:
```jsx
function Document() {
return (
<html>
<head>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<AppShell />
<Suspense fallback={null}>
<PageContent />
</Suspense>
</body>
</html>
);
}
Rule 2: Independent Data = Independent Boundary
如果两个组件从不同端点获取数据,应使用独立的 Suspense 边界,避免一个慢速 API 阻塞两者:
{/* 错误 — 一个慢速 API 会阻塞两者 */}
<Suspense fallback={<Spinner />}>
<UserProfile />
<ActivityFeed />
</Suspense>
{/* 正确 — 独立流式推送 */}
<Suspense fallback={<ProfileSkeleton />}><UserProfile /></Suspense>
<Suspense fallback={<FeedSkeleton />}><ActivityFeed /></Suspense>
Rule 3: Prioritize Above-the-Fold Content
折行以上的内容应放在 Suspense 外部,或放在能快速解析的边界内。
Performance Impact: TTFB vs LCP
| 指标 | 传统 SSR | 流式 SSR | 影响 |
|---|---|---|---|
| 首字节时间(First Byte) | ~200-800ms | ~50-100ms | 更快 |
| 外壳渲染(Shell Paint) | ~400-1200ms | ~100-300ms | 更快 |
| LCP | ~1500-4000ms | ~800-2500ms | 改善 30-50% |
| 可交互时间(Time to Interactive) | ~2000-5000ms | ~1500-3500ms | 更快 |
LCP 得以改善,是因为浏览器在服务器仍在渲染页面深层部分时,就开始加载关键资源了。
Error Handling in Streaming
Error Handling in Streaming
Error 边界包裹 Suspense 边界,以优雅地处理失败,不至于让整个页面崩溃:
function SafeSection({ children }) {
return (
<ErrorBoundary fallback={<p>此区域加载失败。</p>}>
<Suspense fallback={<SectionSkeleton />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
Shell Timeout
在 TanStack Start 中配置外壳超时时间,这样如果数据耗时过长,服务器会回退到静态版本:
createStartHandler({...})({
streaming: true,
suspense: {
shellTimeout: 5_000,
onShellTimeout() {
return handleStaticFallback();
},
},
});
SEO Considerations
一个常见的误解是 Streaming SSR 会损害 SEO。这是错误的。 Googlebot 自 2020 年起就支持流式 HTML 处理。Google 的 John Mueller 已明确表示 Googlebot 增量处理流式 HTML。
不过要确保关键 SEO 内容(title 标签、meta description、h1)放在外壳或早期流式块中,这样搜索引擎能立即看到必要的元数据:
function Document({ url, meta }) {
return (
<html>
<head>
<title>{meta.title}</title>
<meta name="description" content={meta.description} />
<link rel="canonical" href={meta.canonical} />
</head>
<body>
<Suspense fallback={<Loading />}>
<MainContent />
</Suspense>
</body>
</html>
);
}
When Streaming Doesn't Help
- 没有任何数据获取的小型页面 — 传统 SSR 反而更快
- 所有数据在 100ms 内解析完毕的页面 — 收益可忽略
- 从 CDN 边缘服务的高度可缓存页面
- API 响应已很快的页面 — 子 50ms 数据库查询
Streaming 在数据密集型、个性化或动态页面上最能发挥优势——也就是有些内容快、有些内容慢的复杂场景。
Conclusion
Streaming SSR 代表了一种根本性的转变。不再把 HTML 响应当作一个整体块来处理,而是将其分解成独立的数据块,可以逐步生产和消费。
核心要点:
- Streaming SSR 渐进式发送 HTML,而不是一次性发送
- React 19 的
renderToReadableStream和 Suspense 边界使这一方案变得实用 - TanStack Start 通过
deferAPI 提供一流的流式支持 - 数据密集型页面上 LCP 可改善 30-50%
- 对 SEO 无负面影响
- 采用前先测量;流式渲染并非万能方案
延伸阅读,请参阅 Lighthouse 95+ Optimization Playbook 了解衡量流式渲染性能影响的方法。了解如何在 Full-Stack SSR on Cloudflare Workers 中部署流式 SSR,TanStack Start Deployment Guide 则详细介绍了生产环境部署配置。