学完这篇你能搞明白 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 包起来流式推送,配合缓存策略控制服务端压力。