
1. 这套组合到底在解决什么问题先把结论摆在前面RSCReact Server Components和 SSRServer-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 生成的是 HTMLRSC 生成的是组件描述SSR 关注“能不能看到内容”RSC 关注“能不能少传代码、少跑客户端逻辑”。两者服务的不是同一个目标所以不是替代关系而是可以叠着用的关系。2.3 两者的协作方式与核心原理把两者叠起来之后工作流大致是这样的页面请求到达服务端SSR 引擎开始渲染整个应用的外壳 HTML 结构同时 RSC 引擎会在渲染过程中执行所有服务端组件把它们的输出结果以内联或流式片段的方式嵌入到最终的 HTML 里。浏览器先拿到一份带内容的初始 HTML紧接着通过流式传输收到后续的 RSC payloadReact 客户端运行时根据这些 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 里定位一个问题要比之前多跳两三个文件。我习惯在组件头部写清楚它是服务端还是客户端以及依赖了哪些数据来源坚持三个月以后找问题的效率会高很多。一个小习惯但真的很值。