先守住数据,再下沉交互:RSC 组件分层的实战决策法

原文链接

先守住数据,再下沉交互:RSC 组件分层的实战决策法

在真实项目里,React Server Components(RSC)最容易被问成一句话:"这个组件该不该加 use client?"

但这往往已经太晚了。

更好的起点不是组件名称、页面位置,甚至不是首屏性能,而是先问:

这段数据、判断和状态,分别属于谁?又必须在哪个环境里存活?

在 Next.js App Router 中,pagelayout 默认是 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 ActionRoute Handler 必须在每次读取或写入时验证身份与权限;
  • 客户端拿到的 canDelete 应理解为 UI 提示,而不是安全凭证。

Next.js 明确要求把 Server Actions 和 Route Handlers 按公开 API 的安全标准处理,并在每个入口完成授权校验。(Next.js:Authentication)

3. 状态需要活多久?

这是判断客户端边界最有效、也最容易被忽略的问题。

  • 只服务于一次请求的状态:例如当前 URL 参数对应的查询条件、当前用户可见字段、服务端格式化后的金额,适合留在服务端。
  • 跨多次操作持续存在的局部状态:例如已选中的多行、未提交的编辑草稿、弹窗开关、拖拽中的位置、图表缩放范围,适合放进 Client Component。
  • 需要和 URL 同步的状态:例如搜索词、分页、排序、筛选条件,通常应以 URL 为事实来源;客户端负责更新 URL,服务端根据新参数重新读取结果。

关键不是"状态是否存在",而是它是否需要在浏览器中以毫秒级反馈持续演化。

4. 用户是否需要连续、即时的交互?

以下能力是明确的客户端信号:事件处理、useStateuseEffect、浏览器 API、依赖它们的自定义 Hook,以及浏览器专属第三方 SDK。(Next.js:Server and Client Components)

不过,"页面上有按钮"不等于"整页必须客户端化"。

一个只有"展开详情"按钮的卡片,可以让卡片内容仍由服务端生成,只把展开、收起的壳做成客户端组件。真正要下沉的是交互生命周期,而不是把与它相邻的大块内容一并拖到客户端。

三种服务端能力,别混成一个概念

RSC 项目里常见的混乱,是把"运行在服务端"当成同一件事。实际上,它们的职责不同。

能力 主要职责 适合放什么
Server Component 读取数据、组织服务端 UI、输出展示结果 页面壳、数据片段、权限裁剪后的操作区
Server Function / Server Action 接收交互触发的服务端调用,执行受校验的变更 新建、审批、删除、保存草稿
Route Handler 提供具有 HTTP 语义的服务端入口 Webhook、开放 API、下载接口、非 React 调用方

需要特别纠正两个误解:

  1. Server Component 不需要 use server use server 标记的是 Server Function,不是"把组件变成服务端组件"的开关。(React:use server)
  2. 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 后,服务端仍应:

  1. 校验输入格式,例如订单 ID 数量、状态值和备注长度;
  2. 重新读取会话与角色;
  3. 验证用户对每一条订单都有操作权限;
  4. 在事务或一致性策略中执行变更;
  5. 按产品要求刷新相关数据。

如果操作者提交后必须立刻看到自己刚修改的状态,可在 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. 只靠前端隐藏按钮实现"权限控制"

这说明展示层承担了不该承担的安全职责。服务端应在数据读取和变更入口分别校验,而不是把权限寄托在用户看不见某个按钮上。

可执行检查单:每次设计组件前按这个顺序走

  1. 先列数据:它来自哪里?哪些字段不能离开服务端?
  2. 再列判断:哪些规则必须基于可信身份或内部策略执行?
  3. 再列状态:它是一次请求的结果,还是浏览器中持续演化的交互状态?
  4. 最后列交互:真正需要事件、Effect、浏览器 API 或第三方 SDK 的最小单元是什么?
  5. 划 DTO 边界:客户端拿到的是渲染和交互所需的最小可序列化数据,而非服务端对象。
  6. 设计写入口:Action 或 Handler 独立做输入校验、身份校验和权限校验。
  7. 定义写后可见性:用户是否必须立即看到自己的修改,再选择刷新或失效策略。

RSC 的成熟用法,不是追求"更多组件跑在服务端",也不是把客户端交互视为失败。真正的目标是让每一层只承担它天然擅长的职责:

  • 服务端守住数据、密钥、授权和请求级结果;
  • 客户端承接即时、连续、面向用户操作的局部状态;
  • 写入口守住校验、一致性与权限;
  • 两者之间只流动必要、可序列化、可解释的数据。

当团队先划清数据边界,再决定组件运行位置,use client 就不再是一场全页范围的性能争论,而会成为一次小而明确的架构决策。

参考资料

相关推荐
用户9385156350718 小时前
从一个"房子"讲起:为什么 Next.js 是面向 AI 的全栈框架(附博客实战拆解)
ai编程·全栈·next.js
递归尽头是星辰1 天前
大模型 Agent 知识体系:Java 开发者视角下的原理、架构与选型边界
react·智能体·spring ai·java 后端·大模型 agent
赵大仁2 天前
Next.js AI Route Handler 工程化:超时、流式与鉴权
前端·ai·鉴权·next.js·工程化
名字还没想好☜3 天前
Next.js Route Handler 做 SSE 服务端推送:实时进度条、自动重连与什么时候别用 WebSocket
开发语言·javascript·websocket·react·sse·next.js
To_OC5 天前
Next.js + Redis 构建笔记系统:Redis Hash 实战与性能优化
redis·后端·next.js
Richown5 天前
AI + Web3 融合架构:大模型驱动的智能合约自动生成与审计
区块链·react
山海入梦5 天前
Windows 上 Next.js `standalone` + pnpm:「构建成功却把 next 删空」的踩坑记录
next.js
名字还没想好☜5 天前
React 19 useOptimistic 实战:点赞评论先更新 UI,失败自动回滚
前端·javascript·react.js·ui·react·useoptimistic
HjhIron6 天前
手把手教你用 Next.js 14 + Redis 从零搭建一个全栈 Markdown 笔记系统
前端·全栈·next.js