不用 Server Components 也能做 React 流式 SSR:实战指南
React Server Components 几乎垄断了流式 SSR 的话题。但 RSC 不是唯一的方案——对很多应用来说,它带来的复杂性远超收益。
你完全可以用 renderToPipeableStream、defer() 和标准 Suspense 来流式传输 HTML——不需要 Server Components,不需要 "use client" 指令,不需要纠结哪个组件在哪里运行。本文用 Pareto 框架演示具体怎么做。
流式 SSR 到底是什么
Section titled “流式 SSR 到底是什么”传统 SSR 的流程:
- 请求进来
- 服务器获取所有数据
- 服务器渲染完整 HTML
- 浏览器收到完整页面
问题是:任何一个数据源慢,整个页面就慢。200ms 的数据库查询 + 2s 的外部 API = 每个用户至少等 2 秒才能看到首屏。
流式 SSR 的流程:
- 请求进来
- 服务器立即发送 HTML 外壳 + 快数据
- 慢数据在解析完成后逐步流入
- 浏览器逐步渲染每个区域
用户在毫秒级看到内容。慢数据随到随显。没有全页 loading。
三个核心组件
Section titled “三个核心组件”- 分离快慢数据的 loader ——
defer()标记哪些值需要流式传输 - 组件中的 Suspense 边界 ——
<Await>包裹每个流式区域 - 流式 SSR 运行时 —— 底层是
renderToPipeableStream
Pareto 把三者组合在一起。来看一个完整示例。
构建一个流式仪表板
Section titled “构建一个流式仪表板”假设一个仪表板展示:
- 用户数量(快——缓存,~5ms)
- 活动列表(中等——数据库查询,~100ms)
- 分析图表(慢——外部 API,~800ms)
Loader
Section titled “Loader”import { defer } from '@paretojs/core'import type { LoaderContext } from '@paretojs/core'
export async function loader(ctx: LoaderContext) { // 快:先解析再传入 defer const userCount = await getCachedUserCount()
return defer({ userCount, // 已解析——包含在初始 HTML
// 中等:初始 HTML 后 ~100ms 流入 activityFeed: db.query('SELECT * FROM activity ORDER BY created_at DESC LIMIT 20'),
// 慢:初始 HTML 后 ~800ms 流入 analytics: fetch('https://analytics-api.example.com/dashboard') .then(res => res.json()), })}defer() 接收一个对象。同步解析的值(如 userCount)包含在初始 HTML 中。Promise(如 activityFeed 和 analytics)在解析完成后流入。
import { useLoaderData, Await } from '@paretojs/core'
export default function Dashboard() { const { userCount, activityFeed, analytics } = useLoaderData()
return ( <div className="dashboard"> {/* 立即渲染——数据已经解析 */} <header> <h1>Dashboard</h1> <span className="stat">{userCount} 活跃用户</span> </header>
{/* ~100ms 后流入 */} <section> <h2>最近活动</h2> <Await resolve={activityFeed} fallback={<ActivitySkeleton />}> {(feed) => ( <ul> {feed.map(item => ( <li key={item.id}>{item.user} {item.action}</li> ))} </ul> )} </Await> </section>
{/* ~800ms 后流入 */} <section> <h2>数据分析</h2> <Await resolve={analytics} fallback={<ChartSkeleton />}> {(data) => <AnalyticsChart data={data} />} </Await> </section> </div> )}用户看到什么
Section titled “用户看到什么”- 0ms: HTML 外壳 + 顶部用户数量
- ~100ms: 活动列表出现,替换骨架屏
- ~800ms: 分析图表出现,替换骨架屏
对比传统 SSR:用户要等到 ~800ms(等最慢的数据源)才能看到任何内容,然后一次性全部显示。
流式数据的错误处理
Section titled “流式数据的错误处理”当 deferred promise 被拒绝时,<Await> 组件会抛出错误,最近的错误边界捕获它。
import { ParetoErrorBoundary } from '@paretojs/core'
<ParetoErrorBoundary fallback={({ error }) => ( <div className="error-card"> <p>分析数据加载失败:{error.message}</p> <button onClick={() => window.location.reload()}>重试</button> </div>)}> <Await resolve={analytics} fallback={<ChartSkeleton />}> {(data) => <AnalyticsChart data={data} />} </Await></ParetoErrorBoundary>关键:给每个 <Await> 套独立的错误边界。分析 API 挂了,页面其他部分(顶部、活动列表)不受影响。
什么时候不该用流式渲染
Section titled “什么时候不该用流式渲染”不要流式传输 SEO 关键内容。 搜索引擎爬虫可能不会执行 JavaScript 来揭示流式内容。SEO 关键数据应该在 loader 中同步返回。
不要流式传输小数据。 如果所有数据在 50ms 内解析完毕,流式传输的开销不值得。
不要流式传输依赖数据。 如果组件必须所有数据齐全才能渲染有意义的内容,单独 defer 每个数据只会创建多个 loading 动画:
// 更好:一个 loading 状态替代三个骨架屏export function loader() { const [users, posts, comments] = await Promise.all([ getUsers(), getPosts(), getComments() ]) return { users, posts, comments }}客户端导航:NDJSON 流式传输
Section titled “客户端导航:NDJSON 流式传输”初始页面加载时,流式 SSR 逐步传输 HTML。客户端导航呢?
在 Pareto 4.0 中,客户端导航使用 NDJSON(换行符分隔的 JSON)流式传输。点击 <Link> 时,客户端以流的形式获取 loader 数据——非延迟数据先到,延迟数据逐步流入。
Suspense 边界在首次加载和导航时行为完全一致。没有差异,不需要特殊处理。
高负载下的性能
Section titled “高负载下的性能”流式 SSR 不仅改善用户体验——它改变了服务器处理并发请求的方式。
传统 SSR 在所有数据准备好之前一直占用响应。100 个并发连接下,如果每个请求等待 200ms 的 API 调用,服务器队列会迅速堆积。
流式 SSR 立即发送初始 HTML 并释放渲染线程。慢数据异步流入。这就是为什么 Pareto 在高负载下能维持 2,022 streaming req/s,而 Next.js 只有 310 req/s——6.5 倍的差距。
实际意义:一个每秒 2,000 请求的流式 SSR 仪表板,Pareto 需要 1 台服务器,Next.js 需要 7 台。
// loader.ts —— 分离快慢数据import { defer } from '@paretojs/core'
export async function loader() { const fast = await getSyncData() // 先解析 return defer({ fast, // 已解析——包含在初始 HTML slow: fetchExternalAPI(), // Promise——流式传输 })}
// page.tsx —— 标准 React + Awaitimport { useLoaderData, Await } from '@paretojs/core'
export default function Page() { const { fast, slow } = useLoaderData() return ( <div> <div>{fast.value}</div> <Await resolve={slow} fallback={<Skeleton />}> {(data) => <SlowSection data={data} />} </Await> </div> )}
// head.tsx —— 带 loader 数据的 meta 标签export default function Head({ loaderData }) { return <title>{loaderData.fast.title}</title>}不用 Server Components。不用 "use client"。不用框架黑魔法。就是 loader、React 和 Suspense。
npx create-pareto@latest my-appcd my-app && npm install && npm run dev