Streaming SSR 深潜:工作原理与实现指南

深入技术解析 Streaming SSR——揭开其底层工作原理、解释为何能显著提升感知性能,以及如何使用 TanStack Start 实现流式渲染。

Alex Chen
Alex Chen
June 6, 202610 min read
其他语言:Deutsch · English

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 模式的简化版本:

jsx
// 传统 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 就会被推送下来。

jsx
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。以下是其在服务器请求处理器中的用法:

jsx
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> 边界都充当独立的渲染单元。以下布局展示了折行上下的内容如何差异化处理:

jsx
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 作为一等公民来拥抱,仅需一个简单的配置开关即可启用。以下是启用流式的服务器配置:

tsx
// 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 工具处理非关键数据,这些数据可以在初始外壳渲染后再流式传输。以下示例展示了一个博客文章页面:文章内容立即加载,而推荐内容和评论则流式推送进来:

tsx
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 阻塞两者:

jsx
{/* 错误 — 一个慢速 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 边界,以优雅地处理失败,不至于让整个页面崩溃:

jsx
function SafeSection({ children }) {
  return (
    <ErrorBoundary fallback={<p>此区域加载失败。</p>}>
      <Suspense fallback={<SectionSkeleton />}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

Shell Timeout

在 TanStack Start 中配置外壳超时时间,这样如果数据耗时过长,服务器会回退到静态版本:

tsx
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)放在外壳或早期流式块中,这样搜索引擎能立即看到必要的元数据:

jsx
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 通过 defer API 提供一流的流式支持
  • 数据密集型页面上 LCP 可改善 30-50%
  • 对 SEO 无负面影响
  • 采用前先测量;流式渲染并非万能方案

延伸阅读,请参阅 Lighthouse 95+ Optimization Playbook 了解衡量流式渲染性能影响的方法。了解如何在 Full-Stack SSR on Cloudflare Workers 中部署流式 SSR,TanStack Start Deployment Guide 则详细介绍了生产环境部署配置。