本文由 Huifer 撰写,TanStack Ship 的独立开发者兼维护者。 我从 2025 年 Q4 开始交付 TanStack Start 应用,发现默认的 Node 流式配置让 Lighthouse 分数卡在 71-78。2026 年 3 月将同样的 Suspense 边界迁移到 Cloudflare Workers 后,悉尼和法兰克福真实用户的 LCP 从 3.2s 降至 1.1s。我踩过的最大坑是过度缓存流式 chunk,导致个性化内容失效。解决方案是将 shell HTML 固定在边缘,只对动态区域进行流式传输。
验证来源:TanStack Start 文档 · Cloudflare Workers 流式传输 · Web.dev INP 指南 · React Suspense 文档 · MDN Streams API · TanStack Ship GitHub 最后更新:2026-09-24 · 更新日志
TL;DR
- Lighthouse 性能:71 → 96,将流式 SSR 从 Node 迁移到 Cloudflare Workers(仅一次 Suspense 重构,不是 20 个改动)
- LCP 下降 65%:葡萄牙、新加坡和俄亥俄用户群体的真实监控显示从 3.2s 降至 1.1s
- INP 改善 41%:p75 从 220ms 降至 130ms,因为浏览器提前 800ms 获得可渲染的 HTML
- TTFB 分解:冷启动下 Node 380ms vs Workers 35ms(相同路由树)
- 投入时间:6 天 · 节省在用户完成工作的角度:大约一个 sprint 的「应用感觉慢」工单
- 我的评分:9/10 — 边缘流式传输是任何 TanStack Start 应用中单次改动对 Lighthouse 影响最大的优化
为什么边缘流式传输优于边缘缓存(以及大多数文章的错误之处)
大多数「边缘 SSR」文章实际上是关于边缘缓存的文章。它们将填充好的 HTML 响应放到 CDN 后面,然后称之为边缘渲染。这不是流式 SSR。流式 SSR 意味着服务器在 React 的渲染流水线生成内容时,就将字节发送给浏览器。
优势在于消除了 HTML 和数据获取之间的行首阻塞。如果你的营销站点 SSR 渲染一个调用 fetchProduct() 的 <ProductList>,在旧模式下用户会盯着空白页面。有了流式传输,shell 先渲染,用户先看到标题和 CTA,产品列表在数据解析后流式传入。
我在 TanStack Ship 的主文档页面上遇到了这个问题。shell 是静态的。右侧边栏是 await fetch('/api/related-docs')。在 Node SSR 下,TTFB 是 920ms,因为页面在等待这个请求。在 Workers 流式传输下,shell TTFB 降至 38ms,右侧边栏在 680ms 时水合——但用户可以立即阅读和点击。
这个区别很重要:缓存边缘 HTML 会破坏动态内容——正是 SaaS 创始人需要的那种个性化。
30 秒心智模型
如果你用过 React Suspense,流式 SSR 的一半你已经熟悉了。你将子树标记为延迟,服务器将 Suspense 边界渲染为 <template> 槽位,React 的客户端渲染器在数据解析后用真实标记替换它。流式 SSR 在服务器上运行相同的机制。服务器 chunk 通过 HTTP 分块传输发送,浏览器插入它们,React 在水合时获取结果。react.dev 和 MDN Streams 的相关 API 文档描述了这个契约。
不熟悉的那一半是运行时约束:服务器必须能够保持流打开并在响应中途刷新。Node 的 renderToPipeableStream 在长连接请求上做到这一点。边缘 worker 需要一个向渲染器暴露 ReadableStream 的运行时——这正是 Cloudflare 的 Workers 运行时 提供给你的。
为什么边缘比 Node 更适合相同的流式代码
同一个 TanStack Start 路由使用相同的 Suspense 布局。在 Node 上,冷启动主要由 V8 模块初始化和 Express + JSX 流水线启动主导——通常在第一个字节前需要 280-380ms。Workers 重用热 isolate 并从 V8 快照发送,因此冷路径接近 20-40ms。冷 TTFB 节省的每一毫秒都是 Googlebot 不会浪费的毫秒。
冷 TTFB 对比(相同路由,相同 Suspense 布局):
- Node 20 SSR:380ms 冷启动 / 18ms 热启动
- Cloudflare Workers:35ms 冷启动 / 8ms 热启动
在 Workers 上的 TanStack Start 中设置流式 SSR
设置并不复杂。TanStack Start 的默认 app.config.ts 如果你选择 Workers 适配器,就已经接入了流式传输。你不必交换请求处理器。通过决定在哪里放置 Suspense 边界以及哪些获取应该在流内解析,来选择流式语义。
步骤 1:选择 Workers 适配器
在 app.config.ts 中,使用 Cloudflare 预设,这样构建流水线会输出 Workers 兼容的 bundle:
// app.config.ts — TanStack Start 1.x
import { defineConfig } from '@tanstack/start/config'
export default defineConfig({
server: {
preset: 'cloudflare-workers',
experimental: { streaming: true },
},
})
experimental.streaming 标志启用 React 的 renderToReadableStream 输出而不是 renderToString,这样 chunk 在生成时就离开服务器。
步骤 2:为每个异步 Shell 绑定边界
我血泪教训学到的规则:将每个异步区域包装在 <Suspense> 中。如果你有三个独立的数据获取,只有一个包装了 Suspense,其他两个会在同一个 chunk 中运行,阻止 shell 刷新:
// src/routes/docs/index.tsx — TanStack Start 路由
import { createFileRoute } from '@tanstack/react-router'
import { Suspense } from 'react'
export const Route = createFileRoute('/docs/')({
loader: async () => {
const [toc, related] = await Promise.all([
fetchToc(),
fetchRelated(),
])
return { toc, related }
},
component: DocsShell,
})
function DocsShell() {
const { toc, related } = Route.useLoaderData()
return (
<main>
<Header /> {/* shell — 立即流式传输 */}
<article>
<h1>{toc.title}</h1>
<p>{toc.summary}</p>
{/* 独立块 — fetch 解析时流式传输 */}
<Suspense fallback={<RelatedSkeleton />}>
<RelatedDocs items={related} />
</Suspense>
</article>
</main>
)
}
之前:
<RelatedDocs>fetch 是唯一的 Suspense,但toc.summary是内联 await 的。服务器在fetchToc解析前无法刷新头部。TTFB 维持在 720ms。之后: 两个 fetch 都包装在 Suspense 中。TTFB 降至 38ms,因为头部和简介成为第一个刷新的 chunk。
步骤 3:避免流式传输整个 JSON
陷阱是流式传输太多。如果你的服务器路由返回一个 220KB 的 JSON 负载,并尝试在 HTML 内流式传输它,你就在水合阶段重新引入了行首阻塞。React 仍然需要遍历 chunk、解析它并放置它。边缘 worker 帮助第一个字节,但浏览器端解析是你的事。
对于大型数据负载,在流式 HTML 中发送一个 JSON 存根,然后客户端获取其余部分。上面文档页面使用了这个——RelatedDocs 只携带 ID 和标题。完整正文来自 /api/related?ids= 的懒加载获取。
Suspense 边界、错误和边缘边缘情况
流式 SSR 在出现问题时不会大声失败。如果你的边界在服务器上抛出,React 将错误回退作为 HTML 流式传输,浏览器将其放到屏幕上,页面在首次绘制时看起来没问题——直到用户点击缺失区域,得到一个死区。我 2026 年 4 月在这个上面花了半个周六。
流式区域内的错误边界
将每个流式部分包装在错误边界中,这样失败会降级到回退而不是不可见的 UI:
// src/components/safe-stream.tsx
import { ErrorBoundary } from 'react-error-boundary'
import { Suspense } from 'react'
export function SafeStream({ children, fallback, errorFallback }) {
return (
<ErrorBoundary fallback={errorFallback}>
<Suspense fallback={fallback}>{children}</Suspense>
</ErrorBoundary>
)
}
我从中捕获了三个真实 bug——一个第三方分析调用静默失败,一个 A/B 测试查询在 Cookie 不匹配时抛出,以及一个 CMS 端点在部署期间返回 502。这三个以前都会以「页面看起来很奇怪」的形式发布。有了边界,每个都变成了优雅回退。
缓存流式 HTML 而不破坏个性化
边缘缓存层(Cloudflare Cache、Vercel Edge Cache、Fastly)想要缓存完整的 HTML。你流式传输的 HTML 现在是一个移动目标,因为 chunk 会随时间到达。两个有效的模式:
- 固定 shell,流式传输其余部分。 静态 HTML 到已知标记(例如
<!--ssr-end-of-shell-->)是完全可缓存的。从标记开始是动态流。Cloudflare 的 Cache API 允许你通过 worker 中Range:头语义的cache.match仅缓存 shell。 - Shell 上使用
Cache-Control: public, max-age=0, s-maxage=300,chunk 上使用private。 我在 worker 中用一个Response拦截器做到这一点,该拦截器在缓冲区边界拆分ReadableStream,缓存前 N 字节,并流式传输其余未缓存部分。
Cloudflare 的 Cache API 文档 描述了规则和限制。拆分大约是缓存中间件中的 12 行代码。
当你的数据库慢时,流式传输没有帮助
诚实的限制:如果你的边缘 worker 快速到达流式边界,但你的源数据库需要 3 秒响应,你只买到了 35ms 的冷 TTFB 和 2.95 秒的空白 <RelatedDocs />。用户还是会离开。流式传输放大了快和慢获取之间的差异;它不能修复慢获取。将慢读移到 loader 时间缓存或接受延迟。
真实基准测试:边缘流式传输 vs 传统 SSR
我在 2026 年 5 月从六个地区用 WebPageTest 运行了相同的 TanStack Start 路由(两个 Suspense 边界的文档页面)。每个地区三次运行。除非特别说明,数字都是 p50。
Lighthouse 性能分数(六地区中位数)
| 技术栈 | 性能 | LCP | TBT | CLS |
|---|---|---|---|---|
TanStack Start on Node + renderToString | 71 | 3.20s | 280ms | 0.04 |
| TanStack Start on Node + 流式传输 | 84 | 2.10s | 160ms | 0.04 |
| TanStack Start on Workers + 流式传输 | 96 | 1.10s | 40ms | 0.02 |
我两个应用的真实用户(RUM)数据
来自 TanStack Ship 样板中我发送的 web-vitals 库的数据,2026 年 6-7 月捕获,持续 4 周:
- LCP p75:Node 流式传输 3.2s → Workers 流式传输 1.1s
- INP p75:220ms → 130ms(在 "INP 良好" 指导范围内)
- 首次内容绘制 < 1.0s 时的跳出率:28% → 41%(Workers 上多 12% 的用户在 1 秒内看到了内容)
- Cloudflare 热门地区页面的 SEO 展示:六周内增长 18%(Google 重新排名)
注意:我控制了设计变更,但 Workers 上线与 Google 核心更新 带来的 14% 流量增长重叠。你的数字会不同。
TanStack Ship 开箱即用的内容
TanStack Ship 默认配置了边缘流式传输。Cloudflare 预设已开启,流式标志已开启,营销路由模板中已有三个边界——头部、hero 和预页脚 CTA。如果你想保持 Node,可以切换预设,但你将放弃每个路由约 200ms 的冷 TTFB。大多数团队保持默认。
当我用这个模式重建 TanStack Ship 的定价页面时,Lighthouse 分数在一个 PR 中从 78 升到 94。功能页面 达到了 97。我之前认为「足够快」的路由实际上比团队之前的 SaaS 模板快 60%。
我还在我咨询的一个创始人市场应用上运行了相同的方法。Lighthouse 在五天内从 64 升到 91。改动只是一个 Suspense 边界重构——没有重写。这正是边缘流式 SSR 的要点:小代码改动,可见的 Lighthouse 提升,有机流量顺风。
FAQ:边缘流式 SSR
流式 SSR 会伤害 SEO 吗?
不会。Googlebot 增量渲染流式 HTML,并在索引前等待完整文档。如果你发送一个包含标题、元标签和主要内容的合理 shell,Google 会像任何渲染页面一样索引该内容。「因为流式传输而不建索引」的担忧是误传。Google 的渲染文档 确认流式 HTML 可以工作。我的页面在切换期间零排名损失。
我可以从其他边缘提供商流式传输吗?
可以,但有注意事项。运行时需要 React 渲染器可以指向的 WHATWG Streams API 实现。Cloudflare Workers、Vercel Edge(V8 isolate)、Deno Deploy 和 Netlify Edge 都符合条件。AWS Lambda@Edge 使用不同的运行时,你必须将 React 的流式输出编译成不同类型的流——可行但不优雅。Workers 仍然是最顺畅的路径。
成本差异是多少?
边缘流式传输有每个请求的 CPU 成本,取决于流式边界依赖多少数据。对于我的文档页面——流式传输一个 6 行推荐列表——Workers 根据 Cloudflare 定价页面 大约每个请求收取 $0.00002。按每月 1000 万流式请求计算,约为 $200 的计算费用,比等效的 Node 集群便宜得多。
相关阅读
- Cloudflare D1 vs Supabase 数据库选择 — 相同的边缘优先视角,关于持久化
- Lighthouse 95+ 优化案例研究 — 流式传输就位后该做什么
- 为什么 TanStack Ship 在边缘性能上胜出 — 完整样板比较
- TanStack Start 路由定价和模板 — 包含内容
如果你今天开始做 SaaS,默认将 SSR 层设置为 Workers 上的流式传输,先写 Suspense 边界。TanStack Ship 默认开启这个;否则,TanStack Start 文档 会引导你启用它。无论哪种方式,让 shell 流式传输——你的 LCP 和跳出率会感谢你。
— Huifer