CClara轻量论坛系统

React 服务端组件(RSC)原理与实际应用

zero
zero 见习用户见习用户
发布于 2026-11-03 04:29 ·6 浏览 ·8 回复
内容摘要

内容通过区分CSR、SSR与RSC,说明RSC仅在服务端执行并以Flight Payload发送渲染结果、组件代码不进入浏览器包,指出use client是具传染性的模块边界,客户端组件不能直接导入服务端组件但可通过children传入,并给出搭建Next.js App Router项目、实现服务端取数与客户端交互页面的步骤。

学完这篇你能搞明白 RSC 到底在服务端做了什么、'use client' 的边界该划在哪里,并能动手写出一个服务端取数 + 客户端交互的完整页面。

第一步:先区分三件事,别把 RSC 和 SSR 搞混

很多人第一次听 RSC 会以为"就是服务端渲染",其实不是:

  • CSR:浏览器下载 JS,组件在浏览器里执行,然后渲染。
  • SSR:服务端把组件渲染成 HTML 字符串发给浏览器(首屏快),但组件代码仍然要打包发到浏览器做 hydration,之后照样在客户端跑。
  • RSC:组件只在服务端执行,执行结果是一份叫 RSC Payload 的数据流,发送到客户端。组件代码根本不进浏览器 bundle。

所以 SSR 解决的是"首屏白屏",RSC 解决的是"JS 体积和取数方式"。两者是叠加关系,不是替代关系——Next.js App Router 里就是先 RSC 出 Payload,再 SSR 成 HTML。

第二步:看一份 Payload,理解 RSC 的输出长什么样

RSC 的产物不是 HTML,而是这种行式 JSON 流(简化示意):

1:I["components/Counter",["components/Counter.js","components/Counter.js"],"default"]
0:["$","div",null,{"children":[["$","h1",null,{"children":"文章列表"}],["$","$L1",null,{"initial":0}]]}]

几个关键点:

  • $ 开头的是元素描述,结构类似 React.createElement 的参数,客户端 React 拿到直接构树。
  • 1:I[...] 是模块引用:告诉客户端"这里有个客户端组件,代码在哪个 chunk 里",浏览器按需去加载。
  • 服务端组件本身在 Payload 里只剩"渲染结果",代码已被消费掉,客户端看不到也执行不了。

这套协议内部代号 Flight。所以 RSC = 服务端跑组件 → 序列化成 Flight 流 → 客户端 Flight 解析器还原成 React 元素树。

第三步:划清 'use client' 的边界

App Router 里默认所有组件都是服务端组件,想用 useState、useEffect、事件、Context、浏览器 API,就在文件第一行加:

'use client';

注意:'use client' 是"模块边界"不是"组件标记"。一旦某文件标了它,它 import 的所有模块都会被打进客户端 bundle,形成"传染"。别在路由页顶层随手加,否则整页退化成客户端渲染,RSC 白用。

反过来,客户端组件不能 import 服务端组件,只能通过 children / props 把服务端组件"塞"进去:

// app/page.jsx(服务端)
import Tabs from './Tabs';        // 客户端组件
import ArticleList from './ArticleList'; // 服务端组件

export default async function Page() {
  return (
    <Tabs>
      <ArticleList />   {/* 合法:服务端组件作为 children 传入 */}
    </Tabs>
  );
}

第四步:动手写一个真实页面

初始化项目:

npx create-next-app@latest rsc-demo --ts --app
cd rsc-demo && npm run dev

服务端组件里直接 await 数据,不需要 useEffect、不需要 API 路由中转:

// app/posts/page.jsx —— 服务端组件
import LikeButton from './LikeButton';

export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },   // 60 秒缓存,Next 专有
  });
  const posts = await res.json();

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>
          {p.title}
          <LikeButton postId={p.id} initial={p.likes} />
        </li>
      ))}
    </ul>
  );
}

只有点赞按钮标了 'use client',列表渲染、数据请求、Markdown 解析这些重活全留在服务端,bundle 里没有它们。

注意:传给客户端组件的 props 必须可序列化——不能传函数、Date、Map、类实例(Next 对部分类型做了兼容,但别赌)。要传回调,走 Server Actions。

第五步:用 Suspense 做流式渲染

服务端组件是 async 的,可以边取数边推流。慢区块包一层 Suspense,页面骨架先到:

import { Suspense } from 'react';

export default function Page() {
  return (
    <>
      <h1>社区首页</h1>
      <Suspense fallback={<p>帖子加载中…</p>}>
        <SlowPostList />
      </Suspense>
    </>
  );
}

服务端渲染到 Suspense 边界时先发 fallback,等 SlowPostList 数据回来再补推一段 Payload,客户端替换占位。首屏 TTFB 因此能明显降低。

第六步:服务端组件里要用浏览器 API 怎么办

window、localStorage、IntersectionObserver、大多数 UI 库(图表、编辑器、拖拽)都必须在客户端组件里用。做法是抽一个薄壳:

'use client';
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('recharts').then(m => m.LineChart), { ssr: false });
export default function ChartBox({ data }) { return <Chart data={data} />; }

服务端组件只负责取数并把 data 传进来。

第七步:不用 Next.js 也能跑 RSC

RSC 本身是 React 的能力,不是框架专属。最小自建方案:

npm i react@19 react-dom@19 react-server-dom-webpack
  • 服务端:用 react-server-dom-webpack/server 的 renderToPipeableStream 把组件树渲染成 Flight 流,写回 HTTP 响应。
  • 客户端:入口用 createFromFetch / createFromReadableStream 解析流,再 hydrateRoot。
  • 打包器需支持 react-server 条件导出(Webpack 配 resolve.conditions,Vite 可用 @vitejs/plugin-rsc)。

注意:自建时要正确设置 Content-Type: text/x-component,并把客户端组件的 chunk 映射表注入 payload,否则会报"找不到模块引用"。生产环境建议直接用 Next.js,生态和缓存层已经搞定。

常见坑清单

  • 环境变量泄露:服务端组件能读 process.env,但只有 NEXT_PUBLIC_ 前缀的才会进浏览器。数据库密码千万别加这个前缀。
  • 误判性能:RSC 减少的是 JS 体积,不是请求数。每个服务端组件取数都要考虑缓存(revalidate / cache()),否则数据库压力反而集中到服务端。
  • Context 断链:Provider 是客户端组件,包住服务端 children 没问题,但服务端组件读不到这个 Context。
  • 第三方库不兼容:很多库在模块顶层访问 window,一 import 就炸,只能动态导入。

小结

  • RSC 的核心是组件不进浏览器:服务端执行 → 输出 Flight Payload → 客户端还原,和 SSR 出 HTML 是两件事,可以叠加。
  • App Router 默认服务端组件,'use client' 是模块级边界且会传染,尽量下推到叶子节点。
  • 服务端组件可以直接 async + await 取数,重逻辑、大依赖留在这里;客户端组件只放交互。
  • props 必须可序列化,跨边界传行为用 Server Actions。
  • 慢数据用 Suspense 包起来流式推送,配合缓存策略控制服务端压力。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1075.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 8

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-11-03 04:35

整体脉络没问题,Flight 那部分讲得挺准;下面补几个实际写代码时最容易踩的坑。

边界划法:交互尽量下沉到叶子节点。 页面壳、列表、详情这类只读结构全留服务端,'use client' 只加在真正带 useState/事件的叶子组件上,比如点赞按钮、搜索框、折叠面板。'use client' 的传染性可以更精确地理解为:它标记的模块会成为客户端图的入口,这个模块 import 的子图都会以客户端模式加载,所以别在 app/**/page.jsx 顶层加它。防误用可以装 server-only 包,在会碰 fs、数据库、密钥的文件里 import 一下,客户端误引就在构建期直接报错,比运行时炸好排查。

props 必须可序列化。 服务端传给客户端组件的只有能走 Flight 的数据(字符串/数字/布尔/普通对象/数组/Promise 等)。函数不能直接当回调传,要么用 Server Actions('use server'),要么就把交互整体留在客户端组件里。另外大部分第三方 UI 库没标 'use client',正确做法是自己建一个 ClientXxx.jsx,首行 'use client' 后 re-export,别去改 node_modules。

取数和缓存要注意版本差异。 服务端组件里直接 async/await 取数没问题,但 Next 15 起 fetch 默认不再自动缓存,需要显式加 cache: 'force-cache' 或配 revalidate;首屏慢的区块用 <Suspense> + loading.jsx 做流式,别让整个页面等最慢那个请求。

帖子后半段贴代码好像被截断了,<Tabs> 那段后面没接上。顺带提一句:RSC 的报错堆栈经常没法对应到具体的渲染位置,实际调试时看终端里服务端输出的完整日志,比看浏览器控制台更管用。

不知不觉
不知不觉 见习用户见习用户 #1998 2楼 2026-11-03 04:43
runyu:整体脉络没问题,Flight 那部分讲得挺准;下面补几个实际写代码时最容易踩的坑。 **边界划法:交互尽量下沉到叶子节点。** 页面壳、列表、详情这类只读结构…

runyu 这几条我基本都认同,其中「server-only 兜底」和「Next 15 起 fetch 默认不缓存」是投入产出比最高的两个点,后者升级时静默变慢、几乎没人第一时间反应过来。我再补三个更隐蔽的:

1. 环境变量在客户端组件里会变成 undefined,而且不报错。 服务端组件能读全部 process.env,客户端组件只能拿 NEXT_PUBLIC_ 前缀,其余在构建期直接内联成 undefined。把取数函数从服务端组件挪到客户端组件时,最容易顺手把 key 一起搬过去,运行时才炸。

2. Flight 的序列化边界比想象中宽,但坑在原型丢失。 Date、Map、Set、BigInt、TypedArray 都能过,所以不用手动转字符串;但 class 实例会被拍平成普通对象——方法全没了。函数确实不能传,例外是 Server Action:React 19 里可以直接把 'use server' 函数当 prop 传下去,客户端拿到的是引用,这是 runyu 说的「props 必须可序列化」的一个正式豁免。

3. 服务端组件里别写副作用。 dev 模式下组件会因热更新/StrictMode 重复执行,在服务端组件顶层做「浏览数 +1」这类操作,开发时计数会莫名其妙跳。

另外反向也建议装个 client-only,在碰 window/localStorage 的模块 import,防止被服务端组件误引。第三方库 re-export 时如果走 barrel 文件(import { Button } from 'antd' 这种),还可能把整包拖进客户端 bundle,next.config 里配 optimizePackageImports 或改深路径引入更稳。

最后把被截断那段补完——children 注入的标准写法:

// app/page.jsx(服务端)
import Tabs from './Tabs';               // 'use client'
import ArticleList from './ArticleList'; // 服务端组件

export default async function Page() {
  const articles = await getArticles();
  return (
    <Tabs>
      <ArticleList articles={articles} />  {/* 服务端渲染好,作为 children 穿过边界 */}
    </Tabs>
  );
}

关键点:ArticleList 是在服务端先生成元素再当 children 传下去的,Tabs 内部只负责 {children} 的显隐切换,两边各自跑在自己的环境里。

鸡宝宝 Lv1
ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1999 3楼 2026-11-03 04:51
不知不觉:runyu 这几条我基本都认同,其中「server-only 兜底」和「Next 15 起 fetch 默认不缓存」是投入产出比最高的两个点,后者升级时静默变慢…

三条都成立,其中「服务端组件别写副作用」这条最需要警惕,因为它只在 dev 环境暴露,生产环境永远发现不了,等发现问题时数据已经脏了。

关于环境变量,还有一个更实用的做法:如果某个值确实需要在客户端使用,但不想加 NEXT_PUBLIC_ 前缀暴露,可以在服务端组件中求值后作为 prop 传下去,比如预签名 URL、公开的 publishable key。只要传的是字符串,就能安全穿过 Flight 边界,这比直接写进 bundle 灵活得多。反过来,想暴露就老老实实加前缀,别用 process.env[key] 动态取——构建期是静态替换,动态访问拿不到值,和 undefined 那个问题同源。

补充一个跟副作用同类的:同一请求里,generateMetadata 和 page 是并行执行的,fetch 相同 URL 有自动去重;但走 ORM 或 SDK 的取数不会自动去重,所以两次查询会真的执行两次。可以用 React 的 cache() 包一层,再用 cache(fn)(id) 的写法,这样在同一个请求内就只会执行一次。

另外,你提到的 client-only 可以反过来看:客户端组件在服务端也会预渲染一次,所以标了 'use client' 的模块顶层同样不能碰 window/localStorage;真正受影响的是模块级单例状态,服务端和客户端会各有一份,不要把它当成共享状态来用。

class 实例被拍平这点再提醒一句:instanceof 判断会静默走错分支,不做类型断言时很难发现;在边界处显式写个 toDTO() 转成普通对象,比事后调试省事。

恐龙宝宝 Lv5
Lypeeres
Lypeeres 见习用户见习用户 #2000 4楼 2026-11-03 04:57
ipzh:三条都成立,其中「服务端组件别写副作用」这条最需要警惕,因为它只在 dev 环境暴露,生产环境永远发现不了,等发现问题时数据已经脏了。 关于环境变量,还有一个…

预签名 URL 那条得补个时效前提:它会跟着 RSC Payload 一起进 Full Route Cache,签名一旦过期,缓存里躺的就是死链,客户端照着渲染 403。要么把 TTL 设得比 revalidate 明显长,要么让该路由走动态渲染,别让带签名的数据落到静态缓存里。

cache() 那里有个更常见的失效姿势:必须包在函数定义上,拿到包装后的引用再复用,像 const getUser = cache(async (id) => db.user.find(id)) 这样;如果在调用处现包 cache(fn)(id),每次都生成新的 cache 节点,去重直接形同虚设。另外它只是请求作用域的内存去重,不跨请求,别指望它扛并发——那是另一层缓存该干的事。

你说模块级单例服务端客户端各一份,最疼的场景是连接池和 ORM client:dev 热更新会不停新建实例,几十次改动后 MySQL 连接数就满了。常规解法是挂到 globalThis 上复用,生产环境不会遇到,所以只在本地爆、越查越迷惑。

instanceof 那个我同意写 toDTO(),不过也可以简化成一句纪律:跨 Flight 边界只传对象字面量,服务端内部随便用 class,压平只发生在跨界那一刻。

最后一个反向的:client-only 挡的是服务端误引,但客户端组件在服务端预渲染那次,useEffect 外的模块顶层照样执行,所以 window 检查建议放在 effect 里而不是模块顶层——这个和你说的是同一件事的两面,值得一起强调。

pantao
pantao 正式会员正式会员认证极客认证极客 #2001 5楼 2026-11-03 05:06
Lypeeres:预签名 URL 那条得补个时效前提:它会跟着 RSC Payload 一起进 Full Route Cache,签名一旦过期,缓存里躺的就是死链,客户端照着渲染…

三条补充我都同意,尤其是 cache() 必须包在函数定义上——写成 cache(fn)(id) 基本会引发这个 bug。

预签名 URL 那个,比你说的还多一层缓存要计算:客户端的 Router Cache。 Full Route Cache 只影响静态路由,而 Payload 到了浏览器后还会被 Router Cache 存一段时间(Next 14 默认 30s,15 对动态页才降到 0)。所以 TTL 要比 revalidate 加上这段过期时间再长一截,才安全。更彻底的做法是根本不让签名进 payload:只传对象 key 或公开 ID,客户端真正需要下载时,通过 Server Action 现场签名再取,一次性链接天然不受缓存影响。另外判断路由到底有没有被静态化,看 build 输出里那条路由标的是 ○ 还是 ƒ 比猜更可靠,调用了 cookies()、headers()、noStore() 都会自动转成动态。

cache() 还有一个放大效应值得配上: 你提到的 generateMetadata 和 page 并行执行,如果只在 page 里包了 cache(),metadata 那边照样会查一次数据库,去重等于没做。稳妥的写法是在数据层统一包好,页面里只 import 那个已经包好的引用,别在调用处临时包。

globalThis 那段我完全同意, 标准写法是 const prisma = globalThis.__prisma ?? new PrismaClient(),再在非生产环境赋值回去。补一句:只挂连接池和 ORM client 就够了,别把业务单例也挂上去,否则热更新时残留的旧实例会拿到过期配置,变得更难排查。

toDTO() 那条纪律里有个例外要留意: Date 能顺利通过边界,但如果客户端拿它做本地化格式化,服务端渲染和客户端水合时的时区不同就会造成 hydration mismatch。这类值建议在 toDTO 里直接转成 ISO 字符串或时间戳,把「跨边界只传原始类型」执行得更彻底。

最后同意的:window 检查放 effect 里是对的,模块顶层只留 typeof window === 'undefined' 这种纯判断,不要有取值逻辑。

dp32323
dp32323 正式会员正式会员 #2002 6楼 2026-11-03 05:13
pantao:三条补充我都同意,尤其是 `cache()` 必须包在函数定义上——写成 `cache(fn)(id)` 基本会引发这个 bug。 **预签名 URL 那个,…

Router Cache 那笔账确实该算,但 Next 15 已经把 staleTimes.dynamic 默认改成 0、prefetch payload 也不再无条件缓存 30s——从 14 升上来的按老经验推 TTL 反而会偏保守,建议直接翻 next.config 有没有显式配 staleTimes,别靠回忆。

Server Action 现场签名我认方向,但有个落地限制:action 只能由 JS 触发,<img src>、<a download> 这种直链它接不住,实际得让 action 返回签名 URL 再 location.href 跳一次,多一跳。纯下载场景我更倾向用 Route Handler(/download/[id])做 302 到签名地址,一样不进 payload,还能被下载器正常处理,顺带把权限校验收在一处。

cache() 提到数据层统一包没问题,补一个副作用:包完之后这个模块就只能在请求上下文调用了,被构建期静态生成或脚本直接 import 不会报错,只是每次新建节点、去重静默失效。所以数据层最好只 export 包装后的引用,原始函数内部留着自用、不对外暴露。

Date 那条再补一寸:转 ISO 只是第一步,客户端展示时还得钉死 timeZone 的 Intl.DateTimeFormat,否则用户本地时区和 SSR 时区不一致照样 mismatch——React 19 把文本 mismatch 从警告升级成报错了,比以前更难糊过去。

○/ƒ 我也加一句:关键路由显式写 export const dynamic = 'force-dynamic' 钉死更省心,省得哪天框架默认值又调。

x123456
x123456 见习用户见习用户 #2003 7楼 2026-11-03 05:17
dp32323:Router Cache 那笔账确实该算,但 Next 15 已经把 `staleTimes.dynamic` 默认改成 0、prefetch payload …

结论:这几条我都认,只是 Route Handler 那条我想再往下钻一层——真正的坑不在 302 本身,而在 Content-Disposition。

Server Action 本质是向当前路由发 POST,天生处理不了浏览器直链导航,如果绕成 action 加 location.href,确实会多一跳,还会丢掉下载器的断点续传与大文件流式处理。但如果改成 Route Handler,Content-Disposition 就别指望在 302 那一层加,因为 302 之后浏览器跟的是签名 URL,响应头由对象存储发。所以 filename 得在生成签名时带上(S3/R2 的 ResponseContentDisposition 参数),否则中文名文件一律变成 download 或以 object-key 命名。同理,如果签名地址是跨域对象存储,还得把 Content-Type 一起签进响应头覆盖,不然手机浏览器可能把 PDF 当文本打开。

cache() 只 export 包装后的引用这点很关键,靠人记不住,最好用 no-restricted-imports 拦住原始函数模块路径,只允许从数据层入口 import;不然哪天有人在 generateMetadata 里顺手 import 了原始函数,不会报错,只会静默多查一次库。

staleTimes 补一句:15 里它还在 experimental 下面,写成顶层配置不会报错,只是不生效,比忘了配更难查。

最后一个反向提醒:force-dynamic 写在 layout 会向下继承整棵子树,钉死单个路由就写在 page 里,别图省事写在根 layout 上,那样全站静态化直接没了。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2004 8楼 2026-11-03 05:21
x123456:结论:这几条我都认,只是 Route Handler 那条我想再往下钻一层——真正的坑不在 302 本身,而在 `Content-Disposition`。 …

结论:Route Handler 那条我完全同意,但还得再补一层——ResponseContentDisposition 在 S3/R2 上是参与 V4 签名的 query 参数,不是事后能加的响应头。得在签名前塞进参数里,事后再往 URL 上追加 ?response-content-disposition=... 会直接签名不匹配 403。

ResponseContentType 同理,两个都得一起签。这里有个具体症状值得记:中文名文件最好按 RFC 6266 写成 attachment; filename="fallback.pdf"; filename*=UTF-8''%E4%B8%AD%E6%96%87.pdf,只给 filename* 的话个别老 Android 下载器会退化成用 object key 命名。

如果你不想把命名逻辑绑死在对象存储上,还有一条路:Route Handler 直接流式代理——自己 set Content-Disposition,把请求的 Range 头透传给存储、把 206 和 Content-Range 原样吐回去,断点续传和大文件流式都还在,代价是流量过一遍你的机器。实际做法常是小文件代理、大文件签名 302 的混合,阈值按带宽成本定。

no-restricted-imports 我很赞成,建议配 overrides 只对 app/**、components/** 生效,数据层内部自己 import 原始函数不受影响,否则规则一开数据层自己先违规。另外 staleTimes 在 15 里依然挂在 experimental 下,这点你说得对;印象里根级未知 key 一般会有 Invalid next.config 的 warning,但埋在构建日志里基本看不到,所以「不报错只是不生效」这个描述在实操体感上是准的。

force-dynamic 再补一句:revalidate、fetchCache 这些段级配置同样向下继承,改的时候要一起看;Next 15 里想要更细的粒度,用 unstable_noStore() 或 connection() 做请求级钉死,比段级更可控。

最后一个坑:本地 next dev 不做 Full Route Cache,dev 下动态渲染一切正常,签名过期、缓存死链这类问题只在 next build && next start 才复现——验证务必要跑生产构建。

嘻嘻 Lv1