先守住数据,再下沉交互:RSC 组件分层的实战决策法
在真实项目里,React Server Components(RSC)最容易被问成一句话:"这个组件该不该加 use client?"
但这往往已经太晚了。
更好的起点不是组件名称、页面位置,甚至不是首屏性能,而是先问:
这段数据、判断和状态,分别属于谁?又必须在哪个环境里存活?
在 Next.js App Router 中,page 和 layout 默认是 Server Component。服务端组件可以在服务端读取数据并组织 UI;需要状态、事件处理、生命周期逻辑或浏览器 API 时,再用 Client Component 建立局部交互边界。use client 划分的是客户端模块依赖图的入口,而不是给某个 JSX 节点贴上"浏览器运行"的标签。(Next.js:Server and Client Components)
因此,RSC 分层的第一原则可以写成:
先让数据与可信判断留在服务端,再把不可避免的连续交互下沉为尽可能小的客户端岛。
这套原则不承诺"所有页面都更快",但能让权限、数据流、打包边界和后续改造更可控。

不要先按"组件类型"分,先回答四个问题
设计一个新组件或重构一个既有 CSR 页面时,依次判断下面四件事。
1. 数据由谁拥有?
如果组件要直接读取以下资源,默认应从服务端开始设计:
- 数据库、内部 RPC 或仅面向内网的服务;
- API Key、服务端令牌、私有环境变量;
- 由 Cookie 或服务端会话解析出的可信身份;
- 需要按当前用户权限裁剪的字段;
- 不应该完整暴露给浏览器的领域对象。
服务端不只是"更方便请求数据"的位置,它还是数据最小暴露 的边界。传给客户端的应该是为展示和交互裁剪过的 DTO,例如 id、标题、状态、允许执行的动作,而不是 ORM 实体、完整用户档案或内部权限规则。
Next.js 的认证指南建议将授权逻辑集中在数据访问层,并通过 DTO 只返回调用方真正需要的数据。(Next.js:Authentication)
2. 判断是否必须在可信环境完成?
"是否显示删除按钮"可以由服务端根据角色决定;但"用户是否真的能删除这条记录"必须在写入入口再次判断。
浏览器中的条件渲染只能改善体验,不能构成授权。用户可以伪造请求、绕过页面入口,或直接调用暴露的 HTTP 接口。因此:
- Server Component 可以负责读取会话、裁剪可见数据、决定展示哪些操作入口;
- Server Action 或 Route Handler 必须在每次读取或写入时验证身份与权限;
- 客户端拿到的
canDelete应理解为 UI 提示,而不是安全凭证。
Next.js 明确要求把 Server Actions 和 Route Handlers 按公开 API 的安全标准处理,并在每个入口完成授权校验。(Next.js:Authentication)
3. 状态需要活多久?
这是判断客户端边界最有效、也最容易被忽略的问题。
- 只服务于一次请求的状态:例如当前 URL 参数对应的查询条件、当前用户可见字段、服务端格式化后的金额,适合留在服务端。
- 跨多次操作持续存在的局部状态:例如已选中的多行、未提交的编辑草稿、弹窗开关、拖拽中的位置、图表缩放范围,适合放进 Client Component。
- 需要和 URL 同步的状态:例如搜索词、分页、排序、筛选条件,通常应以 URL 为事实来源;客户端负责更新 URL,服务端根据新参数重新读取结果。
关键不是"状态是否存在",而是它是否需要在浏览器中以毫秒级反馈持续演化。
4. 用户是否需要连续、即时的交互?
以下能力是明确的客户端信号:事件处理、useState、useEffect、浏览器 API、依赖它们的自定义 Hook,以及浏览器专属第三方 SDK。(Next.js:Server and Client Components)
不过,"页面上有按钮"不等于"整页必须客户端化"。
一个只有"展开详情"按钮的卡片,可以让卡片内容仍由服务端生成,只把展开、收起的壳做成客户端组件。真正要下沉的是交互生命周期,而不是把与它相邻的大块内容一并拖到客户端。
三种服务端能力,别混成一个概念
RSC 项目里常见的混乱,是把"运行在服务端"当成同一件事。实际上,它们的职责不同。
| 能力 | 主要职责 | 适合放什么 |
|---|---|---|
| Server Component | 读取数据、组织服务端 UI、输出展示结果 | 页面壳、数据片段、权限裁剪后的操作区 |
| Server Function / Server Action | 接收交互触发的服务端调用,执行受校验的变更 | 新建、审批、删除、保存草稿 |
| Route Handler | 提供具有 HTTP 语义的服务端入口 | Webhook、开放 API、下载接口、非 React 调用方 |
需要特别纠正两个误解:
- Server Component 不需要
use server。use server标记的是 Server Function,不是"把组件变成服务端组件"的开关。(React:use server) - Server Action 不是通用数据获取工具。 它更适合处理交互发起的状态变更;读取和渲染仍优先由 Server Component 或专门的数据访问层完成。
换句话说:组件决定"如何呈现",Action 或 Handler 决定"如何安全地变更或对外提供能力"。
组合规则:use client 划的是依赖图,不是视觉树
另一个常见误解是:"Client Component 不能包含 Server Component。"
更准确的说法是:
- 客户端模块不能直接导入并执行服务端组件模块;
- 但服务端可以先渲染 Server Component,再把生成的 JSX 作为
children或其他 props 传给 Client Component; - 一旦文件写了
use client,它导入的模块及其子依赖会进入客户端模块图,因此边界应尽量靠近实际交互点。(React:Server Components)
例如,一个客户端弹窗壳可以接收服务端生成的详情:
tsx
// Server Component
export default async function Page() {
const report = await getReport()
return (
<ReportDialog>
<ReportSummary report={report} />
</ReportDialog>
)
}
tsx
// Client Component
'use client'
export function ReportDialog({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>查看报告</button>
{open ? <div role="dialog">{children}</div> : null}
</>
)
}
这里 ReportDialog 管理浏览器中的开关状态;ReportSummary 则由服务端页面准备数据并渲染。不要因为它们在视觉上是父子关系,就错误地把报告详情整体客户端化。
跨边界传递的数据也不是任意 JavaScript 值。Client Component 的 props 必须可被 React 序列化;普通回调、类实例和多数带原型的对象都不应直接透传。把 DTO 作为边界契约,能避免服务端领域模型意外泄露,也能让组件职责更清晰。(Next.js:use client)
案例:把一个 SaaS 数据列表拆成正确的层
假设你要做一个"订单审核"页面,包含:权限控制、关键词搜索、状态筛选、分页排序、行选择、批量审核、编辑弹窗和导出。
第一层:页面壳留在服务端
app/orders/page.tsx 适合承担:
- 从 Cookie 或服务端会话得到当前用户;
- 校验是否有访问订单列表的权限;
- 解析
searchParams; - 查询当前页数据;
- 根据角色裁剪订单字段;
- 计算每条记录允许出现哪些动作。
这一层不需要 use client。它拥有请求上下文,也最接近数据源和授权规则。
第二层:结果表格优先仍是服务端片段
订单号、客户名称、金额、状态、审核人、服务端计算出的可执行操作,本质上都是当前请求的展示结果。它们不因用户在浏览器中点击一次复选框而必须变成客户端组件。
表格主体可以保持服务端渲染;如果某一列只展示数据,就不要因为"表格通常很复杂"而默认整表客户端化。
第三层:把控制器做成小型客户端岛
以下部分通常需要 Client Component:
- 搜索输入框的防抖和即时输入体验;
- 筛选下拉框的临时选择状态;
- 列显隐偏好;
- 多行勾选;
- 批量操作工具栏;
- 编辑、确认、导出进度等弹窗状态。
但这些控制器不必拥有全部订单数据。一个更稳健的结构是:
text
OrdersPage(服务端:鉴权、查询、DTO)
├─ OrdersFilterController(客户端:编辑筛选条件、更新 URL)
├─ OrdersTable(服务端:按 URL 参数渲染结果)
│ └─ RowActionMenu(客户端:菜单开关、确认弹窗)
└─ BulkActionController(客户端:维护选中 ID、发起提交)

这里的重点是:筛选器管理"用户正在输入什么",服务端页面管理"当前 URL 对应什么结果"。
当用户提交筛选条件时,客户端更新 URL;路由刷新后,服务端依据新的查询参数和当前会话重新查询。这避免了两类常见问题:首屏数据在客户端重复请求,以及浏览器自行决定用户本不该看到的结果。
第四层:批量审核走受校验的写入口
批量审核按钮由客户端触发没有问题,但提交到 Server Action 或 Route Handler 后,服务端仍应:
- 校验输入格式,例如订单 ID 数量、状态值和备注长度;
- 重新读取会话与角色;
- 验证用户对每一条订单都有操作权限;
- 在事务或一致性策略中执行变更;
- 按产品要求刷新相关数据。
如果操作者提交后必须立刻看到自己刚修改的状态,可在 Server Action 中使用 updateTag 处理 read-your-own-writes;如果允许短暂陈旧并在后台更新,则可使用 revalidateTag。这是写后数据一致性的选择,不是决定组件该放服务端还是客户端的依据。(Next.js:Revalidating)
灰区不靠背答案,靠识别触发条件
| 场景 | 默认归属 | 何时需要客户端化 | 不能下放的约束 |
|---|---|---|---|
| 搜索、筛选、分页、排序 | URL 与查询结果在服务端 | 输入防抖、临时筛选面板、无刷新 URL 操作 | 服务端必须按最新参数和权限重新查询 |
| 表单 | 表单结构与初始值可在服务端 | 即时校验、草稿、复杂联动、文件预览 | 服务端必须校验输入与授权 |
| 图表 | 聚合数据和报表说明在服务端 | 缩放、悬浮、刷选、浏览器图表库 | 只传绘图所需的最小数据集 |
| 富文本编辑器 | 内容展示可在服务端 | 编辑器运行时、选区、快捷键、浏览器插件 | 保存时在服务端清洗、校验并授权 |
| 权限按钮 | 服务端决定是否展示 | 菜单开关、确认交互 | 隐藏按钮不等于禁止操作 |
| 国际化 | 服务端读取语言和格式化展示 | 用户即时切换、浏览器本地偏好交互 | 不要把敏感文案规则或权限说明当作客户端事实来源 |
| 埋点 | 服务端可记录请求级事件 | 点击、滚动、曝光等浏览器事件 | 用薄客户端封装,避免 SDK 吞没整页依赖图 |
loading / error |
loading 可围绕服务端异步片段设置;error 是客户端错误边界 |
错误后的重试按钮、局部恢复交互 | 错误信息不得泄露内部实现和敏感数据 |
需要注意,Next.js App Router 中的 error.tsx 必须是 Client Component,因为它需要作为 React 错误边界处理运行时错误;而 loading.tsx 通常用于为路由段提供加载 UI。不要把二者都简单视为"服务端组件能力"。
第三方富文本编辑器、地图、图表和埋点 SDK 经常依赖 window、事件或生命周期。这时推荐给它们加一层薄的 Client wrapper,而不是把页面或全局 layout 标记为 use client。(Next.js:Server and Client Components)
五个信号:说明你的边界已经开始失控
1. 为了一个点击事件,把 page.tsx 标成了 use client
这意味着客户端边界向上吞没了页面的数据读取、展示组件和依赖。应把点击行为抽到更小的按钮、菜单或弹窗组件。
2. 首屏已有的数据又在 useEffect 中请求一次
这通常说明服务端查询结果没有成为页面事实来源,客户端又建立了一套并行状态。除非确实需要独立轮询或实时订阅,否则优先消除重复请求。
3. 服务端数据一路 props drilling,最后才到一个交互按钮
先不要急着把中间树都客户端化。检查按钮真正需要的是完整对象,还是一个 ID、展示文案和有限的权限标记;多数时候,缩小 DTO 比扩大客户端边界更正确。
4. 为跨边界传值,开始传 ORM 实体、类实例或普通函数
这不是"序列化技巧不足",而是在提醒你:服务端模型与客户端视图模型没有分开。建立明确 DTO,回调则留在客户端,写操作改为 Server Function。
5. 只靠前端隐藏按钮实现"权限控制"
这说明展示层承担了不该承担的安全职责。服务端应在数据读取和变更入口分别校验,而不是把权限寄托在用户看不见某个按钮上。
可执行检查单:每次设计组件前按这个顺序走
- 先列数据:它来自哪里?哪些字段不能离开服务端?
- 再列判断:哪些规则必须基于可信身份或内部策略执行?
- 再列状态:它是一次请求的结果,还是浏览器中持续演化的交互状态?
- 最后列交互:真正需要事件、Effect、浏览器 API 或第三方 SDK 的最小单元是什么?
- 划 DTO 边界:客户端拿到的是渲染和交互所需的最小可序列化数据,而非服务端对象。
- 设计写入口:Action 或 Handler 独立做输入校验、身份校验和权限校验。
- 定义写后可见性:用户是否必须立即看到自己的修改,再选择刷新或失效策略。
RSC 的成熟用法,不是追求"更多组件跑在服务端",也不是把客户端交互视为失败。真正的目标是让每一层只承担它天然擅长的职责:
- 服务端守住数据、密钥、授权和请求级结果;
- 客户端承接即时、连续、面向用户操作的局部状态;
- 写入口守住校验、一致性与权限;
- 两者之间只流动必要、可序列化、可解释的数据。
当团队先划清数据边界,再决定组件运行位置,use client 就不再是一场全页范围的性能争论,而会成为一次小而明确的架构决策。