文章

React 服务端组件(RSC)原理

面试向讲清 RSC:它和 SSR 到底有什么区别、'use client' 与 'use server' 两个指令各自标记什么、 RSC Payload 是怎么从服务端流到浏览器的、客户端组件为什么不能 import 服务端组件(children 组合模式怎么救)、 跨边界的值必须可序列化意味着什么,以及面试官最爱追问的"RSC 有什么代价"。

React 服务端组件(RSC)原理

一句话概括

RSC(React Server Components)是 React 19 里稳定下来的一种新组件类型:这类组件只在服务端跑,渲染完之后把”渲染结果”发给浏览器,它自己的代码一行都不会进客户端 bundle。

面试问它,核心就考两件事:第一,你能不能说清它和传统 SSR 的区别(十个人有八个会把这俩混为一谈);第二,你能不能讲明白那道”边界”——哪些代码在服务端、哪些在客户端、数据是怎么跨过这道边界的、跨不过去的东西怎么办。

一句话记住:SSR 解决的是”HTML 什么时候生成”,RSC 解决的是”组件代码该待在哪儿”。 前者是一种渲染时机,后者是一种组件类型。两个维度,经常一起用,但不是一回事。

核心知识点

1. 先分清 RSC 和 SSR(高频开场题)

 传统 SSRRSC
回答的问题首屏 HTML 在哪生成组件代码属于服务端还是客户端
组件代码是否下发下发,浏览器要下载并 hydrate不下发,永远留在服务端
是否能用 useState / onClick能(hydration 之后就是普通客户端组件)不能,服务端没有状态和 DOM 事件
典型产物HTML + 完整 bundleHTML + 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 请求到底发生了什么(讲清流程就够)

  1. 服务端渲染:React 调用服务端组件函数,遇到 await 就等(所以服务端组件可以直接是 async function)。
  2. 碰到 'use client' 组件:服务端不执行它,只在产物里记一条”客户端引用”——模块路径 + 导出名。
  3. 跨边界的 props 被序列化,塞进产物。
  4. 产物(RSC Payload)以流的形式发给浏览器:HTML 片段 + 客户端引用 + 序列化后的 props。
  5. 客户端 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'、跨边界必须可序列化。

本文由作者按照 CC BY 4.0 进行授权