ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RSC+SSR深度解析:从原理到实战,彻底搞懂React服务端渲染新范式

2026/9/19 21:40:43 拓冰建站 浏览量
RSC+SSR深度解析:从原理到实战,彻底搞懂React服务端渲染新范式 如果你在过去两年关注过 React 生态应该对 React Server ComponentsRSC这个名字不陌生。但说实话它在社区里引发的混乱程度远超任何一次 React 特性更新。很多人把 RSC 理解成新的 SSR还有人觉得它是取代 Next.js 的某种框架理解错了就开始在业务里乱用最后被各种边界问题折磨。我做前端已经十年左右从 jQuery 时代一路走到 React 19。最近一年我在两个实际项目里完整接入了 RSCSSR 架构一个是从零开始的 B 端素材管理后台一个是把旧的 Pages Router 项目渐进迁移到 App Router。这两个项目让我彻底搞明白了 RSC 和 SSR 的关系它们不是替代关系而是同一套渲染架构里的两个不同角色合在一起才是完整的解决方案。这篇文章不打算讲太多历史沿革和为什么 React 团队要做 RSC这类官方叙事。我想把真正有用的东西提炼出来RSCSSR 底层到底怎么协作的、边界规则是什么、实战中会踩哪些坑以及什么业务适合真的切换到这套架构。如果你正准备在团队里引入 RSCSSR或者已经在用但被各种诡异报错卡住这篇文章应该能帮你省不少时间。1. 先分清两件事RSC 不是 SSR 的进阶版是渲染决策权前移1.1 传统 SSR 折腾了这么多年痛点到底在哪要理解 RSCSSR 的价值得先回到传统 SSR 的现状。以 Next.js Pages Router 为代表的服务端渲染框架做的事情本质上是在服务端运行 React 组件把渲染结果输出成 HTML 字符串然后发送给浏览器。浏览器拿到 HTML 后再下载一个完整的 JavaScript bundle通过 hydration水合把事件绑定到对应元素上。听起来很美好但问题也随之而来。页面首屏 HTML 确实是服务端生成的可用户访问页面时依然要下载整个应用的 JS bundle才能在浏览器里激活这个页面。一个电商产品页通常有商品卡片、筛选器、购物车、推荐列表传统 SSR 把这些组件全部打包进客户端 bundle不管当前页面用不用得到。结果就是HTML 加载很快但浏览器必须等 JS 解析完才能响应用户操作交互延迟被藏在水合过程里。我在早期一个 SSR 项目里实测过一个中型产品详情页的客户端 JS 压缩后约 1.2MBgzip 后也有 380KB。用户看到首屏 HTML 到页面真正可交互之间隔了差不多 3 秒。这就是传统 SSR 的核心痛点服务端只帮你预先算好 HTML 字符串但所有组件逻辑和数据获取逻辑仍然要完整地搬到客户端执行一遍。1.2 RSC 给 SSR 补上的关键一块组件维度的服务端执行RSC 的出现解决的是上面这个听起来无解的问题。它的思路不是服务端渲染 HTML而是服务端执行组件渲染逻辑。也就是说RSC 让 React 组件在服务端真实运行并且只把渲染结果以序列化数据的形式发给客户端客户端不需要重新执行一遍相同的组件逻辑。这里有个容易混淆的点。传统 SSR 是在服务端执行组件但执行完之后依然是整个应用作为一个整体输出成一个 HTML 文档。RSC 则不同它把应用拆分成两个阵营服务端组件默认情况下 App Router 里的所有组件都是 Server Components和客户端组件文件顶层声明 use client 的组件。服务端组件可以在服务端读取数据库、访问文件系统、调用内部 API并且不会被打包进客户端 bundle。举一个直观的例子。以前做一个商品列表页筛选器逻辑和数据请求逻辑放在客户端组件里用户打开页面时这些逻辑的代码全部要下载到浏览器。现在用 RSCSSR商品列表主体是 Server Component它在服务端把数据读出来、渲染成静态内容直接发给客户端只有筛选器这个需要交互的部分才被标记成客户端组件。用户在浏览器里真正拿到的是一部分已经渲染好的静态内容加上一个体积小得多的交互组件。1.3 用一句话记住 RSCSSR 的分工如果非要压缩成一句话来记忆我会这样说SSR 负责把首屏 HTML 在服务端生成好让用户快速看到内容、让搜索引擎能抓到页面结构RSC 负责把不需要客户端逻辑的组件留在服务端执行只把结果流式地传给客户端从根上减小客户端 bundle。也就是说RSCSSR 不是一个二选一的问题也不是RSC 替代 SSR的问题。它们是一个完整架构里的两个层次SSR 管输出版本RSC 管渲染边界。我在给团队做分享时经常用一个类比传统 SSR 是把整道菜在厨房做好连锅一起端到餐桌上客人要什么需要什么步骤都得在桌上重演一遍RSCSSR 是厨房先做完全部能预做的菜只把需要现做的那道菜连同必要的工具客户端组件代码一起端到桌上。客人在桌上的操作变少了上菜速度自然快。2. 底层原理RSC 协议、客户端边界与序列化规则2.1 一条 RSC 流从服务端到客户端的完整旅程要理解 RSCSSR 为什么能流式传输得先搞清楚 RSC 的产物到底是什么。很多人以为 RSC 输出的也是 HTML其实 RSC 引擎输出的是一种JSON-like 的序列化数据流业内叫 RSC Payload。它包含的是一棵组件树的描述每个节点的组件类型、props、状态、以及子节点的嵌套关系而不是最终的 DOM 字符串。实际请求一个 App Router 页面时浏览器会收到两个东西一个是传统的 HTML 文档用于首屏展示和 SEO另一个是 RSC Payload 流。HTML 保证用户第一时间能看到内容React 在客户端拿到 RSC Payload 后进行 reconciliation协调把它映射成客户端组件树。由于服务端组件的逻辑已经在服务端执行完了客户端拿到的是结果描述所以不需要再下载这些组件的源码。这个机制有一个非常实际的效果客户端 bundle 体积只包含客户端组件对应的代码。一个页面里如果有 80% 的内容是服务端组件那么这 80% 的代码就不需要被浏览器下载。我第一次跑通这个架构时把原来 380KB 的 gzip JS 降到了 140KB 左右整整少了 60% 以上感觉非常直观。2.2 use client 不是客户端专用是序列化边界use client 可能是 RSC 概念里被误解最多的一个指令。很多教程说它是用来标记客户端组件的这个说法没错但不够准确。它真正的含义是告诉 React从这里开始组件及它引用的依赖需要进入客户端 bundle它们与父级 Server Component 的边界是序列化边界。这个边界的意义在于props 跨边界传递时必须遵守序列化规则。Server Component 渲染 时options 这个 prop 会被序列化传输到客户端而 onChange 如果是在服务端定义的函数就没法被序列化传过去因为函数无法在其闭包上下文缺失时恢复执行。这也是 RSCSSR 初期的经典报错来源。实际开发中需要注意use client 是模块级指令一旦一个文件声明了它这个文件里导出的所有组件都是客户端组件。一个常见的模式是客户端组件文件里继续 import 其他没有 use client 的普通组件这些普通组件也会被一起打包进客户端 bundle。所以做组件拆分时要时刻保持边界清晰的意识能被序列化的直接传 props不能被序列化的通过 children 插槽传递用组合方式来绕过边界。2.3 Suspense 如何让 RSCSSR 产生并行请求 流式交付的效果RSCSSR 还有一个让开发者很兴奋的能力流式渲染Streaming。在传统 SSR 里服务端必须等所有数据都拿到才能输出完整 HTML。如果页面里有一个接口特别慢整个首屏都会被拖慢。RSCSSR 结合 Suspense 之后这个问题被彻底改掉了。具体来说一个 Server Component 可以是一个异步组件比如 async function ProductList() 内部 await fetch(/api/products)。在渲染过程中React 遇到一个尚未 resolve 的异步组件时会先输出一个 placeholder即 Suspense 的 fallback让服务端先把已准备好的 HTML 发出去。等到数据就绪再通过流式传输把真正的组件内容补发给客户端。这意味着用户看到首屏的时间不再是等所有接口都完成的时间而是等最快能渲染的部分完成的时间。我在一个内容型项目里用这个特性优化了一个多数据源聚合页原来首屏 TTFB 在 2.8 秒左右改造后用 Streaming Suspense 把 TTFB 压到了 800ms 左右剩下的数据陆续流式到达页面逐段填充。3. 实操接入从零搭一个 RSCSSR 的物料管理页3.1 环境与项目骨架Next.js App Router 默认就是 RSCSSR如果你用的是 Next.js 13.4 以上的 App Router恭喜你你已经在 RSCSSR 的环境里了。App Router 是 React Server Components 的官方参考实现app 目录下的所有组件默认是 Server Components除非文件里声明了 use client。创建一个新项目命令很简单npx create-next-applatest rsc-demo --typescript --app cd rsc-demo这个脚手架默认会开好 App Router。我建议把 TypeScript 选上RSC 的 props 序列化边界在类型提示下会直观很多后面维护起来也轻松。跑起项目后你会看到 app/page.tsx 这个文件它就是一个 Server Component默认不支持 useState 和 useEffect要用的地方就得单独加 use client。3.2 服务端组件读取数据并渲染表格主体我用一个物料管理页来演示。页面需求很简单展示一批物料数据支持按照类型筛选。物料数据存在服务端一个 JSON 文件里也可以是数据库查询结果。在 RSC 里读取数据这件事可以直接在组件函数里执行不需要额外的数据请求层。新建 app/materials/page.tsx内容如下import { MaterialsTable } from /components/MaterialsTable; import { FilterPanel } from /components/FilterPanel; async function fetchMaterials() { const res await fetch(http://localhost:3000/api/materials, { next: { revalidate: 60 }, }); if (!res.ok) throw new Error(Failed to fetch materials); return res.json(); } export default async function MaterialsPage() { const materials await fetchMaterials(); const categories [...new Set(materials.map((m: { category: string }) m.category))]; return ( section h1物料管理/h1 FilterPanel categories{categories} / MaterialsTable materials{materials} / /section ); }注意几个关键点。第一MaterialsPage 是一个 async 函数这在 Server Components 里是合法的因为 React 在服务端会等待它 resolve。第二fetch 默认会开启缓存和记忆化。next: { revalidate: 60 } 是一层数据缓存策略60 秒内重复请求会命中缓存适合物料这种更新频率不高的数据。这里的 FilterPanel 和 MaterialsTable 我故意分成了两个组件后面会解释为什么这么拆。3.3 客户端交互组件如何通过 children 拼进来FilterPanel 需要用户点击筛选所以必须有交互逻辑它必须是客户端组件。新建 components/FilterPanel.tsxuse client; import { useCallback, useState } from react; export function FilterPanel({ categories }: { categories: string[] }) { const [activeCategory, setActiveCategory] useState(全部); const handleChange useCallback((category: string) { setActiveCategory(category); // 在这里触发筛选逻辑 }, []); return ( select value{activeCategory} onChange{(e) handleChange(e.target.value)} classNamerounded border p-2 option value全部全部/option {categories.map((category) ( option key{category} value{category} {category} /option ))} /select ); }这个组件只接收 categories 这样一个可序列化的字符串数组作为 props不依赖服务端组件传函数。筛选操作的具体逻辑比如切换表格内容通常是配合 URL 参数或客户端路由来做的这样能保持 Server Component 的收益每次筛选状态变化时可以把新的查询参数带到 URL 上Server Component 重新执行获取新数据。这里我特别想强调一个组合模式客户端组件不要把服务端组件直接 import 进来如果不得不嵌套把服务端组件作为 children 传给客户端组件。比如export default async function MaterialsPage() { const materials await fetchMaterials(); return ( section FilterPanel categories{categories} MaterialsTable materials{materials} / /FilterPanel /section ); }在 FilterPanel 内部用 {children} 渲染表格部分。这样表格主体仍然是 Server ComponentFilterPanel 只提供了交互壳子。React 的文档明确推荐这种方式因为它避免了客户端组件在打包时把服务端组件代码也拉进 bundle。3.4 用 Suspense 把阻塞式加载改成流式加载如果页面里有多个数据源有的接口快有的接口慢全部 await 再渲染会拖慢整个首屏。RSCSSR 里最好的做法是拆成独立的异步组件用 Suspense 分别控制 fallback让每个组件各有自己的加载态。可以这样改造import { Suspense } from react; import { MaterialsTable } from /components/MaterialsTable; import { FilterPanel } from /components/FilterPanel; import { MaterialsTableSkeleton } from /components/MaterialsTableSkeleton; async function MaterialsTableWithData() { const materials await fetchMaterials(); return MaterialsTable materials{materials} /; } export default async function MaterialsPage() { return ( section h1物料管理/h1 FilterPanel categories{[电子元件, 五金件, 包装材料]} / Suspense fallback{MaterialsTableSkeleton /} MaterialsTableWithData / /Suspense /section ); }这样做的效果是页面主体和筛选器先渲染出来物料表格区域显示一个骨架屏等 fetchMaterials 完成后再流式替换。用户感知到的加载速度明显更快而不是一直在白屏等一个全有或全无的响应。4. 踩坑集RSCSSR 真正难的不是概念是边界行为4.1 客户端组件引入服务端组件这个报错比我想象中更快出现第一次在项目里用 App Router 时我很快就遇到了一个报错Server Component cannot be imported into a Client Component. 当时有点懵因为我觉得组件互相引用是很正常的事。后来想明白了客户端组件的代码会被打包进浏览器 bundle如果服务端组件被它 import服务端组件的逻辑比如读取数据库、使用 process.env就会被错误地打包进客户端这显然是不安全的。解决方式就是前面说的组合模式把服务端组件作为 children 传给客户端组件而不是在客户端组件文件里直接 import。这是 RSC 架构下组件组织方式的最大改变用习惯之后其实很顺手——它强迫你更明确地思考每个组件运行在哪个环境里。还有一个相关的坑服务端组件引用一个第三方库时如果这个库内部依赖了 window 或 document运行时会报错。比如某些 UI 组件库的样式计算依赖 window 尺寸。解决思路是要么选一个支持 SSR 的库版本要么用动态导入把这类库限定在客户端组件里加载。4.2 函数与类实例过不了边界序列化的物理限制RSC 的 props 序列化规则是目前很多人没搞清楚的细节。我整理了一张常见类型的跨边界传输表方便你在设计组件 props 时心里有数类型能否跨 RSC 边界传递说明string / number / boolean / null可以完全支持undefined可以在 props 中会被忽略Array / 普通 Object可以必须是可序列化结构Date / Map / SetReact 19 支持旧版本可能丢失方法函数 / 类实例 / Blob / File不可以无法恢复闭包和内部状态循环引用的对象不可以序列化会失败Promise不直接支持async component 内部处理最让我吃过亏的是在早期 React 18 版本里把一个 Date 对象传给客户端组件结果客户端收到的是字符串导致日期格式化逻辑全部失效。升级到 React 19 后这个问题修复了但团队如果还在老版本这类问题依然会出现。设计跨边界组件的 props 时原则很简单只传最必要的数据尽量避免传复杂对象。宁可多传几个基本类型字段组合也不要传一个看起来很方便的类实例。4.3 网络面板里的双请求不是 bug是 SSR Payload 两条流用 RSCSSR 跑页面时打开 DevTools 的 Network 面板会看到一个页面同时返回 HTML 和 RSC Payload很多第一次接触的人会误以为请求重复了。其实这是正常机制HTML 用于初始化显示和 SEORSC Payload 用于 React 在客户端恢复组件树状态。尤其在点击路由跳转时浏览器不会像传统 MPA 那样加载整页 HTML而是只请求一个新的 RSC Payload 流React 在客户端用它来更新视图。这种软导航机制用在数据表格、内容详情这类场景中非常舒服页面切换几乎无闪烁同时 URL 还是真实可分享的。调试时一个小技巧在 Network 面板过滤 fetch/xhr 类型找名为 ?_rsc... 的请求那个就是 RSC Payload。如果想看原始序列化内容直接看 Response 里的 JSON-like 结构你会看到类似 {children: [...]} 这样的树形描述。4.4 缓存与动态数据的博弈多少次 revalidate 都不够RSCSSR 的缓存策略是另一个大坑。App Router 里有 Router Cache、Full Route Cache、Data Cache 三层缓存层级复杂我的团队在迁移初期因为数据更新不及时误判了很多次页面是不是写错了。最典型的问题是你在服务端组件里 fetch 了一个接口并把结果渲染到页面但更新了数据库后页面数据一直不刷新。原因通常是 Full Route Cache 或者 Data Cache 命中根本没有重新执行组件函数。解决方式有几种export const dynamic force-dynamic强制该路由不做静态缓存每次请求都重新渲染。fetch(url, { next: { revalidate: 60 } })设置时间窗口60 秒内走缓存之后重新验证。fetch(url, { cache: no-store })完全跳过 Data Cache。最让我困惑的是 Next.js 15 里 fetch 默认缓存策略又从强缓存改回了不缓存所以如果你看到网上教程讨论 fetch 默认行为一定要确认框架版本。我的经验是尽量显式声明缓存策略不要依赖默认值。团队规范里明确规定所有 fetch 必须带 cache 或 next 字段哪怕是写一个注释说明为什么可以开缓存。4.5 服务端组件出错时页面会白屏这是 RSC 架构下很容易被忽略的问题。客户端组件里如果抛错可以通过 React Error Boundary 捕获显示一个友好的错误 UI。但服务端组件的错误发生在服务端渲染阶段一旦抛出整个流式响应可能中断导致用户看到半截页面甚至白屏。App Router 提供了 error.tsx 和 global-error.tsx 来兜底。error.tsx 必须是一个客户端组件它会在服务端组件抛错时替代出错的那部分 UI。所以每个页面的目录结构里我都建议至少加一个 error.tsx同时配合加载态的 loading.tsx这样即使接口挂了用户至少能看到错误提示而不是无限加载或白屏。5. 什么业务值得切到 RSCSSR一个判断框架与迁移路径5.1 一张表对比 SPA、传统 SSR、RSCSSR很多团队在要不要上 RSCSSR 之间纠结。我建议先不要听厂商宣传而是拉一张表对照自己的业务形态最为直接对比维度传统 SPA传统 SSRPages RouterRSCSSRApp Router首屏 HTML 是否服务端生成否是是客户端 JS 体积大整个应用打包大所有组件逻辑仍需下载小只包含客户端组件代码数据获取位置主要在客户端服务端 客户端服务端为主客户端处理交互交互响应速度高客户端全量交互中等受水合影响高只有客户端组件参与交互SEO 友好度差好好流式渲染不支持部分支持原生支持学习成本低中中高边界和序列化规则需要理解生产稳定性高高中等框架升级速度快踩坑资料尚在积累从表格能看出来RSCSSR 并不适合所有场景。如果你的业务是强客户端交互型的比如画布编辑器、复杂仪表盘、实时协同文档传统 SPA 依然是好选择硬切到 RSC 反而限制重重。但如果你的业务以内容浏览、数据展示、电商列表为主RSCSSR 的优势非常明显。5.2 三类典型业务场景的接入建议第一类是内容型站点比如博客、文档站、营销官网。这类业务的核心诉求是 SEO 和首屏速度页面交互简单绝大多数组件都没有客户端状态。这种情况非常适合直接上 RSCSSR甚至可以说 App Router 的默认范式就是为此设计的。我改造过一个内部文档站点迁移后最明显的感受是文档正文不需要写任何客户端状态逻辑全部 Server Component代码量还变少了。第二类是数据密集型后台比如电商后台、素材管理、数据报表。页面有大量表格、筛选器、分页但交互集中在上方筛选区和表格操作列。这种情况建议采用混合策略列表主体用 Server Component筛选器和操作按钮用客户端组件通过 URL 参数或搜索参数驱动服务端组件重新取数。改造后主包体积会大幅下降同时筛选交互很流畅。我那个素材管理后台就是走这条路线的首屏 JS 从 380KB 降到 140KB首次交互可用时间TTI减少了 47%。第三类是重交互且数据实时性要求极高的工具类应用比如协同白板、在线设计工具、监控大盘。这类应用的数据变化频率高客户端状态复杂服务端组件带来的收益有限反而要频繁处理缓存失效问题强行使用会让自己陷入维护泥潭。这类业务我建议保持 SPA 或传统 SSR 思路等框架对动态场景的支持更成熟后再考虑平滑迁移。5.3 从 Pages Router 渐进迁移到 App Router 的路径如果已有项目用 Pages Router我不建议一次性重写。比较稳妥的路径是先在 Next.js 里启两个目录pages 和 app 共存把流量小、结构简单的页面逐步迁到 app 目录。Next.js 官方支持这种渐进迁移模式app 目录可以引入 pages 目录里已有的组件。迁移顺序可以参考我走过的路先迁全局 layout把 provider、导航栏之类的基础设施先搬过去再迁数据量不大、交互不复杂的展示页面验证组件拆分边界是否清晰最后迁交互较重的列表页针对边界报错逐项处理。每一轮迁移后都跑一遍核心用户流程观察 bundle 体积和 LCP 指标确认没有回退再继续下一步。整个迁移周期拉长到一个迭代周期甚至两个迭代周期都完全正常。App Router 的缓存语义和组件边界需要团队消化强行在一个大版本里全部换掉风险会集中在回归测试阶段一起爆发。我个人在实际项目中还有一个体会RSCSSR 的收益不是装了框架就会自动出现的它需要团队真正理解组件边界并且愿意在代码里显式地维护这些边界。如果你只是把原来的页面代码原样拷贝进 App Router大概率只会得到一个更难维护的版本。如果你愿意花时间去拆解哪些组件是真正需要交互的、哪些可以留在服务端那 RSCSSR 带来的 bundle 体积减少和加载体验提升会远超你的预期。最后分享一个小技巧每次新建一个路由页面之前先问自己一个问题——这个页面在没有 JS 的情况下还能不能完整展示核心内容如果能说明你大概率正确使用了 RSCSSR 的思路如果完全不能那说明页面逻辑被过度依赖在客户端了需要重新考虑边界设计。