React 服务端组件(RSC)原理
面试向讲清 RSC:它和 SSR 到底有什么区别、'use client' 与 'use server' 两个指令各自标记什么、 RSC Payload 是怎么从服务端流到浏览器的、客户端组件为什么不能 import 服务端组件(children 组合模式怎么救)、 跨边界的值必须可序列化意味着什么,以及面试官最爱追问的"RSC 有什么代价"。
一句话概括
RSC(React Server Components)是 React 19 里稳定下来的一种新组件类型:这类组件只在服务端跑,渲染完之后把”渲染结果”发给浏览器,它自己的代码一行都不会进客户端 bundle。
面试问它,核心就考两件事:第一,你能不能说清它和传统 SSR 的区别(十个人有八个会把这俩混为一谈);第二,你能不能讲明白那道”边界”——哪些代码在服务端、哪些在客户端、数据是怎么跨过这道边界的、跨不过去的东西怎么办。
一句话记住:SSR 解决的是”HTML 什么时候生成”,RSC 解决的是”组件代码该待在哪儿”。 前者是一种渲染时机,后者是一种组件类型。两个维度,经常一起用,但不是一回事。
核心知识点
1. 先分清 RSC 和 SSR(高频开场题)
| 传统 SSR | RSC | |
|---|---|---|
| 回答的问题 | 首屏 HTML 在哪生成 | 组件代码属于服务端还是客户端 |
| 组件代码是否下发 | 下发,浏览器要下载并 hydrate | 不下发,永远留在服务端 |
| 是否能用 useState / onClick | 能(hydration 之后就是普通客户端组件) | 不能,服务端没有状态和 DOM 事件 |
| 典型产物 | HTML + 完整 bundle | HTML + RSC Payload + 少量客户端 bundle |
你可以这样理解:SSR 是把页面在服务端”预渲染”一遍,但客户端还是要把整棵树的 JS 下载下来重新激活一遍;RSC 是干脆让一部分组件永不参与激活——它们渲染完就”死”在服务端了,浏览器只拿到渲染结果。
所以 RSC 最大的收益不是”首屏快”,而是包体积小 + hydration 成本低。
2. 两个指令,别搞反(最容易答错的一题)
1
2
3
4
5
6
7
// 'use client':标记"从这里开始进客户端 bundle",是模块图的边界
'use client';
import { useState } from 'react';
export default function LikeButton({ id }) {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>👍 {n}</button>;
}
1
2
3
4
5
6
7
8
9
10
// 'use server':标记"这是个可以被客户端调用的服务端函数",跟组件无关
// Server Component
import Button from './Button';
function EmptyNote() {
async function createNoteAction() {
'use server'; // ← 标记的是函数,不是组件
await db.notes.create();
}
return <Button onClick={createNoteAction} />;
}
面试必背的两句:
- 没有”标记服务端组件”的指令。 在 Next.js App Router 里,默认就是服务端组件,不用写任何东西。
'use server'是给函数用的,官方叫 Server Functions;只有当它被传给action属性或在 action 内部调用时,才叫 Server Action(2024 年 9 月之后官方统一了这套命名)。
顺带说清 'use client' 的一个隐含代价:它是”传染”的。 一旦某个文件写了 'use client',它 import 的所有模块也一起进了客户端 bundle。所以边界要尽量往下压,别在页面顶层就 'use client'。
1
2
3
4
5
6
// ❌ 顶层就标 client,整棵子树的工具函数、格式化库全被打进 bundle
'use client';
export default function Page() { return <><HeavyMarkdown/><LikeButton/></>; }
// ✅ 只把真正需要交互的叶子节点标成 client
export default function Page() { return <><HeavyMarkdown/><LikeButton/></>; }
3. 一次 RSC 请求到底发生了什么(讲清流程就够)
- 服务端渲染:React 调用服务端组件函数,遇到
await就等(所以服务端组件可以直接是async function)。 - 碰到
'use client'组件:服务端不执行它,只在产物里记一条”客户端引用”——模块路径 + 导出名。 - 跨边界的 props 被序列化,塞进产物。
- 产物(RSC Payload)以流的形式发给浏览器:HTML 片段 + 客户端引用 + 序列化后的 props。
- 客户端 React 按引用去加载对应的客户端模块,用传过来的 props hydrate 那几个交互孤岛。
关键在第 2 步:服务端组件传给客户端组件的不是”组件”,是”已经渲染好的结果”。
4. 谁 import 谁:单向依赖 + children 组合模式
规则只有一条:服务端组件可以 import 客户端组件,反过来不行。 因为客户端组件一旦 import 了服务端组件,就等于要浏览器去执行一段只能跑在服务端(要连数据库、读文件)的代码。
那”客户端组件包着服务端内容”这种需求怎么办?用 children / props 传进去,别 import:
1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ 服务端父组件把服务端渲染好的内容当 children 传给客户端外壳
// Modal 是客户端组件(要 useState 控制开关),Content 是服务端组件(要查库)
export default function Page() {
return (
<Modal>
<Content /> {/* 在服务端渲染完,作为不透明的 JSX 传下去 */}
</Modal>
);
}
// ❌ 反过来在客户端组件里 import 服务端组件 —— 直接把服务端代码拖进 bundle
'use client';
import Content from './Content'; // 不行
这是 RSC 里最实用的一招,面试说出来很加分:客户端组件拿到的是”渲染好的 JSX”,它不知道里面是什么,也不需要知道。
5. 跨边界只能传”可序列化”的值
React 的序列化比 JSON 宽一点:原始值、数组、普通对象、Map/Set/Date/TypedArray、以及 Promise、JSX 元素、Server Function 引用都能过;函数、类实例、非全局 Symbol 不行,传了直接报序列化错误。
1
2
3
4
5
// ❌ 函数过不去边界
<ClientTable formatter={(row) => row.name} />
// ✅ 把格式化逻辑定义在客户端组件内部,或传一个可序列化的配置
<ClientTable format="name" />
还有一个面试官喜欢的追问点:“能序列化”不等于”该传”。 整条数据库记录传下去,字段可能泄露、payload 也会变大——只传客户端真正需要的字段。
6. 数据获取:为什么 RSC 能干掉一堆瀑布流
1
2
3
4
5
// ✅ 服务端组件里直接 await,没有 useEffect、没有 loading 态、没有额外接口
export default async function Page() {
const [user, list] = await Promise.all([getUser(), getList()]);
return <Profile user={user} list={list} />;
}
对比客户端 useEffect 里发请求:组件挂载 → 渲染 loading → 请求 → 再渲染,而且父组件拿到数据后子组件才开始请求,一层层串成瀑布。RSC 里数据就在服务端,组件渲染和数据获取是同一件事。
再配合 Suspense 做流式:外层壳子先到,慢的那块用 fallback 占位,好了再流式补上——用户不用等最慢的那个接口。
7. 代价是什么(不答这题显得没踩过坑)
- 服务端组件没有交互能力:不能用
useState、useEffect、useRef、浏览器 API,也不能用on*事件。 - 心智负担:每个文件你都得想”这个组件在哪边跑”,团队里没人管的话很快全成
'use client',收益归零。 - 依赖框架支持:RSC 在 React 19 里是稳定的,但”实现 RSC 的打包器/框架底层 API”官方明确说不遵循 semver,React 19.x 的小版本可能变。所以实际上你是通过 Next.js 这类框架在用 RSC,很少裸写。
- 服务端有成本:每次请求都要渲染,需要缓存策略兜底;不是所有页面都适合。
其实你每天都在用
- 用 Next.js App Router 写过页面:
app/page.tsx默认就是服务端组件,你不用写'use client'。 - 一个 markdown 博客页:
marked+sanitize-html加起来几十 KB,放在服务端组件里渲染,客户端 bundle 一个字节都不增加。 - 列表页里只有”点赞”按钮要交互:整页服务端组件,只有按钮是
'use client'。 - 表单提交:
<form action={createNoteAction}>,createNoteAction是'use server',不用自己写/api/xxx。 - 后台管理页直接查库:
await db.user.findMany()写在组件里,不用再包一层 REST 接口。 - 主题/权限这类全局配置:在根 layout(服务端)读 cookie 或 session,直接传给下面的客户端组件。
常见误解(FAQ)
❌ 误区1:”RSC 就是 SSR,换了个名字。”
不是。SSR 是”在服务端生成首屏 HTML”,但组件 JS 照样下发、照样 hydrate;RSC 是”这个组件的代码根本不下发、永不 hydrate”。一个是渲染时机,一个是组件归属维度,二者可以同时用。
❌ 误区2:”给服务端组件加 'use server' 才是服务端组件。”
React 没有标记服务端组件的指令。默认就是服务端组件,'use server' 标记的是”可以被客户端调用的服务端函数”。
❌ 误区3:”'use client' 只是加在这一行上,影响不大。”
它是模块图边界:这个文件以及它 import 的所有东西都会进客户端 bundle。写在越靠近根的地方,被打进去的代码越多。边界要压到真正需要交互的叶子节点。
❌ 误区4:”客户端组件里 import 一个服务端组件也能跑。”
直接 import 不行(服务端代码进不了客户端 bundle)。但可以把服务端组件作为 children 或普通 prop 从服务端父组件传进客户端组件——服务端先渲染,客户端拿到的是结果。
❌ 误区5:”props 随便传,React 会帮我处理。”
传函数、类实例、非全局 Symbol 会直接报序列化错误。回调要定义在客户端组件内部;传对象时只传必要字段,别整条记录往下丢。
❌ 误区6:”用了 RSC 客户端就没有 JS 了。”
错。客户端组件照旧要下载、要 hydrate。RSC 减少的是非交互部分的 JS,交互部分一分不少。
一句话总结
RSC 让”不参与交互的组件永远不下发”——记住三件事就够了:默认服务端、交互 'use client'、跨边界必须可序列化。