ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RSC与SSR协作原理:从流式渲染到组件边界实战解析

RSC与SSR协作原理:从流式渲染到组件边界实战解析

1. 这套组合到底在解决什么问题

先把结论摆在前面:RSC(React Server Components)和 SSR(Server-Side Rendering)不是两个互斥的技术选型,而是同一套渲染体系里分工明确的两层。真正让它们产生化学反应的,是 React 18 之后引入的流式渲染架构,以及像 Next.js App Router 这种把两者揉进同一个路由模型的框架。我在实际项目里第一次正式把它们搭配使用,是接手一个数据密集型的业务后台改造。改造前页面是纯客户端渲染,白屏时间最长能到两三秒,接口请求链路长得像一串葡萄;改造后首屏 HTML 几乎秒出,数据在服务端直接取完,客户端 JS 体积砍掉了将近一半。这种体感上的差异,是单聊 SSR 或者单聊 RSC 都解释不了的,因为收益来自两者叠加之后的工作方式变了。

如果你之前只是听说过 RSC 和 SSR,但一直没搞清楚它们到底谁管谁、怎么配合,这篇文章会把这套东西的完整链路拆开给你看。我会讲清楚两者的原理分工、在真实项目里怎么配置、以及我踩过的那些坑。适合正在用或打算用 Next.js App Router、Remix 这类全栈 React 框架的开发者,也适合那些对 React 服务端执行机制好奇、想搞明白底层逻辑的人。

2. SSR 和 RSC 的分工逻辑

2.1 传统 SSR 的局限在哪里

传统 SSR 的模型其实很简单:服务端拿到当前请求的 URL,匹配到对应的组件树,把整棵树渲染成一段 HTML 字符串,然后塞进响应里返回给浏览器。浏览器直接解析 HTML 就能看到页面内容,不用等 JS 加载完。这个模型解决了“首屏白屏”和“SEO 可索引”两个核心问题,在很长一段时间里都是 React 服务端渲染的标准答案。

但它有两个硬伤常年存在。

第一个硬伤是数据获取效率低。在传统的 SSR 框架里,服务端虽然能渲染组件,但组件内部的 effect 逻辑(比如 useEffect 里发请求)依然不会在服务端执行。你必须借助框架提供的特殊 API,比如早期的 getServerSideProps、getStaticProps,或者更早的 componentWillMount 之类,把数据提前取好再塞给组件。这就造成一种割裂:页面的数据需求和 UI 结构被硬生生拆成了两层,改一个数据接口可能要同时动两处代码。

第二个硬伤是“全有或全无”的渲染策略。传统 SSR 必须等服务端把整个组件树渲染完,才能一次性返回 HTML。只要树里有一个叶子组件需要查慢速数据库,整棵树的渲染都会被拖住。即便后来出现了类似“部分 SSR”的变体,本质上还是在把整棵组件树整体输出,只是切分渲染时机,并不是组件级别的精细控制。

2.2 RSC 让“服务端”变成组件能力

RSC 的出现改变了“服务端”在 React 里扮演的角色。在 RSC 架构里,“服务端执行”不再是一个页面级别的开关,而是一个组件级别的属性。任何组件,只要没标记成客户端组件,默认就在服务端运行。它可以直接 await 数据库查询、读取文件系统、调用内部服务,然后只把最终渲染结果发给浏览器。

注意这个措辞:发的是“渲染结果”,不是完整代码。更准确地说,服务端会把组件渲染成一种叫做 RSC Payload 的轻量描述结构。浏览器拿到这份 payload 后,React 在客户端重建对应的组件树。整个过程中,组件自身的业务代码、依赖的三方库,都不需要下载到浏览器。这意味着你的服务端组件里哪怕 import 了一个足足 500KB 的加密库,客户端也不会因此多背一克 JS。

这就是 RSC 与传统 SSR 最核心的区别:SSR 生成的是 HTML,RSC 生成的是组件描述;SSR 关注“能不能看到内容”,RSC 关注“能不能少传代码、少跑客户端逻辑”。两者服务的不是同一个目标,所以不是替代关系,而是可以叠着用的关系。

2.3 两者的协作方式与核心原理

把两者叠起来之后,工作流大致是这样的:页面请求到达服务端,SSR 引擎开始渲染整个应用的外壳 HTML 结构,同时 RSC 引擎会在渲染过程中执行所有服务端组件,把它们的输出结果以内联或流式片段的方式嵌入到最终的 HTML 里。浏览器先拿到一份带内容的初始 HTML,紧接着通过流式传输收到后续的 RSC payload,React 客户端运行时根据这些 payload 把组件树逐步水合(hydrate)成可交互的应用。

这里有一个非常关键的细节:流式渲染。React 18 的 renderToPipeableStream 允许你一边渲染一边输出,不必等整棵树完成。挂在 Suspense 边界上的慢组件可以先吐一个 fallback,等数据到了再补上真实内容。配合 RSC,这个机制能做得更细腻:某个服务端组件查数据库慢了,响应照样可以打到一半,先输出已就绪的部分,慢的那块最后以流式补丁的形式跟上。

所以再直白一点:SSR 提供的是页面的“壳”和“地基”,让内容可访问;RSC 提供的是“模块化服务端执行能力”,让数据、权限、计算都尽量留在服务端。两者共享同一个请求管道,各干各的活,最后拼成一个完整页面。

3. 从零搭一个 RSC + SSR 的全栈页面

3.1 框架与版本选择

如果从零开始搭,我推荐直接用 Next.js 的 App Router。它把 RSC 和 SSR 做成默认行为,不需要写复杂的配置。你新建一个页面文件,默认就是服务端组件;在文件顶部加一行"use client",它就变成客户端组件。这种显式标记的设计对心智负担的降低非常明显。

版本选择上,React 必须是 18.3 或更高,推荐直接用 19.x。Next.js 用 14 或 15 都可以,我自己的项目用的是 15。需要注意如果你们团队里有老项目从 Pages Router 迁移过来,建议先确认涉及的第三方库是否兼容 App Router,尤其是那些在客户端组件里大量依赖 window 对象的库。

3.2 一个最小示例的完整实现

我拿一个典型的“数据列表 + 交互筛选”页面来演示。这个页面要展示一篇文章的正文,以及底部的评论区。正文需要从数据库读取,评论区需要用户交互(输入、点赞)。

先看服务端组件的写法:

// app/posts/[id]/page.tsx import { getPost } from '../../../lib/db'; import { Comments } from './Comments'; export default async function PostPage({ params }: { params: { id: string } }) { const post = await getPost(params.id); return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> <Comments postId={post.id} /> </article> ); }

这个组件没有"use client"标记,所以默认是服务端组件。注意它用了 async 关键字,这在客户端组件里是完全不允许的,但服务端组件天然支持。getPost 是直接在服务端执行的数据库查询,查询结果不会经过网络请求的环节,直接就作为 props 传给了组件。

再看客户端组件的实现:

// app/posts/[id]/Comments.tsx 'use client'; import { useState } from 'react'; export function Comments({ postId }: { postId: string }) { const [comment, setComment] = useState(''); async function submitComment() { // 调用评论接口 await fetch(`/api/posts/${postId}/comments`, { method: 'POST', body: JSON.stringify({ content: comment }), }); setComment(''); } return ( <div> <input value={comment} onChange={(e) => setComment(e.target.value)} placeholder="写下你的评论" /> <button onClick={submitComment}>提交</button> </div> ); }

这个组件凡是需要 state、事件处理、浏览器 API 的地方,都必须放在"use client"标记的组件里。它接收的 postId 是从服务端组件传下来的普通序列化数据,所以跨边界传输没有问题。

从服务端组件传子组件给客户端组件,是一个值得单独说的技巧。如果我想让评论区上方始终显示一条来自服务端的系统提示,但这条提示又不想让客户端 JS 打包进去,正确的做法不是直接在客户端组件里 import 一个服务端组件,而是把它作为 children 传下来:

// 服务端组件 export default function PostPage({ params }) { const post = await getPost(params.id); return ( <article> <h1>{post.title}</h1> <Comments postId={post.id}> <Notice message="请文明发言" /> </Comments> </article> ); }
// 客户端组件 'use client'; export function Comments({ postId, children }) { return ( <div> {children} {/* 交互逻辑 */} </div> ); }

这会创建一道特殊的“插槽”,服务端组件渲染出来的内容会以静态节点的形式嵌入客户端组件的输出里。这个模式在实际项目中特别常用,它避免了把不必要的东西拖进客户端 bundle。

3.3 流式加载的配置方法

上面的示例已经能跑,但没有发挥流式渲染的全部潜力。如果正文查询特别慢,评论区又是交互组件,理论上我们可以让正文先展示,评论区不急。这个需求用 Suspense 实现。

import { Suspense } from 'react'; import { PostContent } from './PostContent'; import { Comments } from './Comments'; export default function PostPage({ params }: { params: { id: string } }) { return ( <article> <Suspense fallback={<p>正文加载中...</p>}> <PostContent id={params.id} /> </Suspense> <Suspense fallback={<p>评论区准备中...</p>}> <Comments postId={params.id} /> </Suspense> </article> ); }

这样做的效果是:SSR 输出的初始 HTML 里,正文区域是一个加载占位,评论区区域也是一个加载占位。浏览器显示出来,然后流式响应分两批把真实内容补齐。用户感知到的首屏时间会非常短,不用等最慢的那个数据接口。

这里有一个很容易忽略的点:Suspense 的 fallback 本身也是首屏 HTML 的一部分,所以它应该尽量轻量、不要包含任何耗时的数据操作。我见过有人把 fallback 里又塞了一个服务端组件去查数据库,结果完全失去了流式加载的意义,反而拖慢了首屏。

3.4 生产环境构建与部署说明

开发环境下,Next.js 会同时处理 SSR 和 RSC 的开发构建,热更新速度比 Pages Router 时代略慢,这是正常现象。如果页面组件特别多,可以考虑用 Turbopack 实验特性加速,但生产构建我建议还是用默认的 webpack,稳定性更好。

部署时,应用必须跑在支持 Node.js 的环境中。RSC 和 SSR 都需要服务端运行时,不能像纯静态站点一样直接丢到 CDN 上。这里有一个部署细节:如果应用里有动态渲染的页面(比如依赖 request 的 headers、cookies),整个路由会退化成动态渲染,无法在构建时静态生成。这种页面在每次请求时都会触发服务端渲染,流量大时需要做好缓存策略。可以给页面配 revalidate 时间,用增量静态再生成的方式折中。

4. 核心配置参数与数据边界理解

4.1 服务端组件与客户端组件的边界规则

这部分是 RSC 最容易踩坑的地方。这个边界规则就是“我传给你的,必须能序列化”。

具体说就是:服务端组件可以传给客户端组件任何可序列化的数据:字符串、数字、布尔值、普通对象、数组、日期、Map、Set。但是函数、类实例、以及带有循环引用的对象,都不能直接跨边界传递。这意味着你不能把一个大函数作为 prop 从服务端组件传给客户端组件。这种设计是有意的:函数代表的是客户端要执行的逻辑,一旦传过去就相当于把代码下载到客户端,打破了 RSC 减少 JS 体积的核心目标。

另一个边界是模块层面的。一个文件里如果写了"use client",那么整个模块导出的所有组件都会被当作客户端组件。所以不要把一堆工具函数和组件混在同一个文件里,否则那些无状态、纯计算的服务端函数也会被迫进入客户端包。

实际上我在一个项目里就踩过这个坑。当时把格式化时间戳的工具函数和页面组件写在同一个文件里,页面组件标记了"use client",结果那个工具函数被一起打包进了客户端 bundle。虽然函数很小,但背后 import 的 dayjs 也跟了过去,凭空多了几十 KB。从那以后,我定了一条规矩:所有可能被服务端组件 import 的纯函数,都必须单独放在一个不带"use client"的文件里。

4.2 缓存策略与数据新鲜度

RSC + SSR 模式下的缓存比传统 SSR 要复杂一个维度,因为存在三张独立的缓存:浏览器 HTTP 缓存、框架层的请求缓存、还有服务端组件的数据缓存。

Next.js App Router 里,fetch 请求默认会被缓存。这意味着你在服务端组件里写一个普普通通的 fetch,第一次请求后会缓存结果,后续同页面上的请求不会真正发出去。如果你希望每次请求都拿新数据,fetch 的第二个参数需要显式声明:

// 动态数据,每次请求都要拿最新值 const res = await fetch('https://api.example.com/stats', { cache: 'no-store', }); // 也可以按秒级频率重新验证 const res = await fetch('https://api.example.com/trending', { next: { revalidate: 60 }, });

这个设计很容易被误解。实际开发中我遇到过同事写了个内部接口,功能是计数用户点击,每次刷新都应该加一。但因为默认缓存的存在,数值在半小时内纹丝不动,排查了很久才发现是 fetch 被缓存了。

另外一个关键点是动态 API 的联动效应。在服务端组件里如果用到了 cookies 或 headers 相关的函数,页面会被自动标记为动态渲染。这在部分路由上是正确行为,但如果整个应用所有页面都因此变成动态渲染,会白白损失很多性能。我的处理方式是尽量把动态操作限制在客户端组件里,服务端组件保持纯净的“读数据 + 渲染”职责。

4.3 代码拆分图景的重新审视

传统 SSR 中,代码拆分依赖 React.lazy 和 Suspense,开发者要自己考虑该在哪个层级拆分。RSC 把这个机制从“手动”变成了“默认”:每个服务端组件天然是代码分割边界,它引用的依赖不会进入客户端 bundle,只有当组件被标记为客户端组件时,依赖才会整体打包。

这就带来一个奇妙的特性:你可以直接在服务端组件里 import 一个体积巨大但只在服务端有用的库,模块几乎零成本。我曾经在一个内部工具页面里用到了一段代码,它引用了一个图表渲染库,这个库只在生成的 HTML 里输出 SVG 字符串。传统做法要把这个库排到服务端 bundle,配置 webpack externals,或者把图表生成逻辑放到接口里。RSC 下面,我直接在服务端组件里调用它,然后输出一段静态 SVG 给客户端,实现代码只有几十行,库本身完全不会传到浏览器。

但你也要反过来想:客户端组件树的体积不会因为 RSC 自动变小,只有在服务端组件层“拦”住那些库,才真正省下 JS。所以设计组件边界时,要本能地去问一句:这个组件真的需要 interactivity 吗?如果只是展示一个格式化结果,放在服务端组件里才是最优解。

5. 实战中踩过的坑与排查方法

5.1 水合不匹配错误

这是 SSR 场景下最经典的问题之一。原因是客户端渲染出来的内容和服务端渲染出来的 HTML 对不上,React 在水合时会报错,直接的表现是页面内容闪烁、交互失效,或控制台出现 "Hydration failed because the initial UI does not match what was rendered on the server"。

排查思路分三步。先看是不是随机数或 Date 相关:服务端渲染时取了一次时间,客户端渲染时又取了一次,两次数值不一样。解决办法是在客户端组件里用 useEffect 后更新,或者把时间格式化放到服务端完成。再看是不是环境差异:服务端没有 window,所以任何直接访问 window 的代码都会在服务端抛错,更隐蔽的是在渲染过程中读取 localStorage,服务端读到 undefined,客户端读到具体值。最后看是不是第三方库的交互特性导致:有些 UI 组件的默认状态在不同环境不一样,可能需要设置默认值。

5.2 客户端组件里只能传序列化 props

这是 RSC 边界最让新手抓狂的一个问题。你写了一个服务端组件,想传个回调函数给客户端组件:

// 这是错的,会报错 function ServerComponent() { const handleClick = () => console.log('hello'); return <ClientComponent onClick={handleClick} />; }

React 会直接报错 "Functions cannot be passed directly to Client Components unless you explicitly expose it"。这不是 bug,是设计约束。解决方式是把事件处理逻辑直接放在客户端组件内部,如果确实需要在服务端定义动作,可以通过传标识字符串,让客户端组件自行查找和触发。

我在一个项目里见过一个团队试图把整张数据表格的列配置(包含 render 函数)从服务端传到客户端组件,结果各种报错,最后靠这篇文章里的模式解决:服务端组件渲染一个 JSON-config,客户端组件根据 config 匹配对应的内部渲染函数。这样既保留了服务端的数据权限控制,又满足了客户端的自定义渲染需求。

5.3 RSC Payload 体积失控

RSC 省了 JS,但 payload 本身也不是零成本。如果你在服务端组件里把一个超大的数组原封不动传给客户端组件,这些数据会以序列化形式出现在 payload 里,传输体积可能比原来还大。

解决办法是:在传之前先做裁剪。只传展示必需的字段,不要一股脑把整个数据库对象传过去。对于特别大的列表,优先考虑分页或虚拟滚动。如果跑电商类页面,商品列表的摘要信息往往几百个字段,但展示只需要名称、主图、价格三样。刚迁移到 RSC 的团队经常忽略这个,结果首屏字节数暴涨,性能反而比原来纯客户端时更差。

5.4 动态渲染导致的性能回退

如果你在服务端组件里直接读取了 cookies 或者 headers,页面就会变成强制动态渲染。这是一个容易被忽略的隐性坑,因为你的页面代码看起来不过是多调用了一次 useCookies 或 similar 函数,页面又没有报错,但构建日志里会显示该路由变成了 ƒ (Dynamic),之前肉眼看不太出来。

排查方法是去 Next.js 的构建输出里看每个路由的渲染模式标识。如果是动态渲染,且页面内容并不真正依赖请求信息,可以把动态操作放到客户端组件里处理,或者用中间件实现部分逻辑。如果确实需要动态渲染,那就要接受性能成本,考虑加 CDN 缓存或增量静态再生成。

5.5 服务端组件的 useEffect 不会执行

这个坑很多人遇到时都会懵一瞬:组件看起来渲染了,但 useEffect 完全没有触发的迹象。原因很简单:useEffect 是客户端生命周期方法,服务端组件在服务端执行,根本没有浏览器生命周期这一说。

如果一定要在客户端组件里执行副作用,就把组件标记为"use client"。但反过来想,你在“服务端组件里写 useEffect”这个念头本身就是设计错误。需要副作用,说明需要客户端运行时;不需要副作用,直接写逻辑在渲染里就行。

5.6 快速排查清单

[] 组件是否意外被标记为"use client"

[ ] 是否把函数、类实例或非序列化对象传给了客户端组件

[ ] 服务端组件里有没有直接访问 window、document、localStorage

[ ] fetch 请求是否被默认缓存住了,导致数据不更新

[ ] 页面是否因为动态 API 调用被强制动态渲染

[ ] Suspense fallback 是够轻量

[ ] RSC payload 里有没有不必要的超大字段

6. 我个人实践之后的几个建议

这套组合用了大半年,最大的体感是“数据流的逻辑耦合度降低了”。原来要规划接口层、状态层、渲染层,现在很多数据在服务端直接取、直接渲染,少了好几个中间层。代码量没有明显变多,但页面的可维护性提升很明显。

有几个建议留给后续要上手的读者。

第一,不要试图把所有组件都变成服务端组件。交互频繁的区域,比如搜索框、筛选控件、表单,天然就是客户端组件,不用为了“省 JS”硬把它们拆碎。合理的做法是服务端组件管数据、管布局、管权限,客户端组件管交互、管状态、管体验。两者各自该干什么,从组件的核心职责出发判断。

第二,RSC + SSR 的最佳实践团队形态通常是小而完整的。一个页面从数据查询到 UI 渲染到交互逻辑,两三个开发者完全可以全程搞定,不需要专职的接口开发岗在中间转手。这对团队角色划分有一定影响,如果你在带团队,要做好心理准备。

第三,如果你想深入了解底层工作机制,建议装 React DevTools 并在 Network 面板里观察 RSC payload 的传输。你会直观地看到哪些组件的数据在服务端序列化、哪些组件是空的壳、哪个时刻开始水合。没有比“观察真实数据流动”更快的学习路径了。

第四,如果项目不是 React 技术栈,也不急着硬套这套架构。RSC 是 React 专属的组件执行模型,Vue、Svelte 各有自己的服务端渲染方案。关键是理解“组件级服务端执行”这个思路,比记住某个框架的 API 更有长期价值。

最后说一个维护层面的经验:这套架构下,一个页面的“真实代码量”分散在服务端组件、客户端组件和 RSC payload 里,定位一个问题要比之前多跳两三个文件。我习惯在组件头部写清楚它是服务端还是客户端,以及依赖了哪些数据来源,坚持三个月以后,找问题的效率会高很多。一个小习惯,但真的很值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进