React Server Components 在真实项目中的边界:哪些组件该放在服务端
React Server Components(RSC)最容易引发两种极端做法:要么把整个页面写成 Client Component,沿用传统 React 的习惯;要么误以为"服务端组件更快",于是试图把所有逻辑都塞进服务端。
真实项目中,组件归属不应由"它是不是一个页面"或"它有没有一点交互"决定。更可靠的原则是:默认放在服务端;只有确实依赖浏览器、交互状态或客户端生命周期的最小子树,才划为客户端边界。
本文以 Next.js App Router 为主要落地语境。RSC 是 React 的架构能力,但路由、缓存、Server Actions 和部署运行时的具体行为仍取决于框架实现。
先纠正一个概念:RSC 不等于 SSR,Client Component 也不等于"只在浏览器渲染"
在 Next.js App Router 中,page.tsx 和 layout.tsx 默认是 Server Components。首次加载一个路由时,Next.js 会在服务端生成 HTML 预览和 RSC Payload,并提供 Client Components 所需的 JavaScript;浏览器随后为交互部分完成 hydration。
因此:
- Server Component:组件逻辑运行在服务端环境,可直接访问数据库、内部服务、密钥或请求上下文;其组件实现不会作为客户端交互 JavaScript 发送给浏览器。
- Client Component:能使用事件处理、状态、Effect、浏览器 API 等客户端能力;它不意味着首次加载时完全不参与服务端生成 HTML。
- SSR:描述的是服务端预先生成 HTML 的渲染过程;RSC 则进一步定义了哪些组件逻辑可以留在服务端,以及客户端与服务端组件如何组合。

把它理解为两层分工会更准确:服务端负责读取、裁剪和组装首屏内容;客户端负责用户操作之后的局部交互与持续变化。
一句话决策规则:先问"是否必须在浏览器运行"
为一个组件决定归属时,按下面顺序判断。
- 它是否需要事件、状态、Effect 或浏览器 API?
- 是:它或其最小交互子树必须是 Client Component。
- 否:继续判断。
- 它是否需要数据库、内部 API、密钥、Cookie、请求头或服务端权限判断?
- 是:优先是 Server Component。
- 它只是一个交互容器,内部内容并不需要客户端能力吗?
- 是:容器可为 Client Component,但内容可以继续由服务端渲染后以
children或插槽形式传入。
- 是:容器可为 Client Component,但内容可以继续由服务端渲染后以
- 数据是首屏读取,还是需要用户驱动刷新、实时订阅、轮询或乐观更新?
- 首屏读取:优先服务端。
- 持续变化:在客户端交互叶子中引入相应的数据机制,并将写入或敏感读取保留在服务端接口。
- 跨边界传入的数据能否序列化?
- 不能:重构边界、传递普通数据或 JSX 插槽,而不是试图把函数、类实例或复杂服务对象直接传给客户端。
核心不是"交互组件必须少",而是:客户端代码的覆盖范围必须与它真实需要的浏览器能力一致。
哪些组件应优先放在服务端
以下是强烈的服务端信号。
1. 页面、布局与大多数展示层
页面骨架、导航结构、文章正文、商品信息、价格说明、服务端生成的 SEO 内容,以及不需要本地状态的展示组件,通常都应保留为 Server Components。
这样做的直接收益不是抽象意义上的"更先进",而是这些组件及其依赖不会被无谓地打进浏览器包。页面可以在服务端完成数据读取和结构拼装,浏览器只接收真正需要交互的代码。
2. 直接读取数据库或内部服务
服务端组件可以直接调用 ORM、数据库客户端或内部领域服务。以下示例采用 Next.js 当前文档中常见的异步 params 写法;若项目仍使用较早版本的 Next.js,应按该版本的路由参数类型调整。
tsx
// app/products/[id]/page.tsx
import { getProductById } from '@/lib/products'
import { AddToCartButton } from './add-to-cart-button'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProductById(id)
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>{product.price}</strong>
<AddToCartButton productId={product.id} />
</main>
)
}
这里,商品查询属于服务端:它是初始读取,可能依赖数据库凭据,也不需要浏览器状态。加入购物车按钮则是一个清晰的客户端叶子。
不要为了"前后端分离"而让 Server Component 再通过自己的 Route Handler 请求商品数据。这样会增加一次 HTTP 往返;在构建期预渲染的场景中,甚至可能没有可供请求的应用服务器。服务端组件读取自身所需数据时,优先直接调用数据访问层。
3. 使用密钥、令牌和私有环境变量
第三方服务 token、支付服务密钥、内部 API 凭据、数据库连接信息,都不应进入客户端模块图。只要组件需要使用这些信息,它就应留在服务端,或者将敏感操作封装在服务端函数中。
这不是性能优化,而是安全边界。
4. 请求上下文与真正的权限判断
用户会话、Cookie、请求头、租户标识,以及"该用户是否能读取这份数据"的判断,通常属于服务端职责。
例如,后台数据看板可以在服务端读取当前会话并查询被授权的组织数据;客户端得到的应该是已经过滤、裁剪后的 DTO,而不是完整记录和"是否显示"的判断任务。
客户端的权限判断只适合改善体验,例如隐藏无权限按钮、提前展示引导文案;它不能作为数据隔离或写操作授权的唯一防线。
5. 数据聚合、格式化和重计算
如果一个视图需要聚合多个服务、裁剪字段、计算统计指标、生成推荐结果或进行昂贵格式转换,通常应先在服务端完成。
判断标准不是"计算量大就一定服务端",而是看这段计算是否需要在每个用户浏览器里重复执行,以及它的输入是否本来就只存在于服务端。若答案是肯定的,服务端更自然。
哪些组件必须留在客户端
以下能力是明确的客户端信号。
1. 事件处理和局部状态
onClick、onChange、useState、useReducer 等能力需要浏览器中的 React 运行时。
tsx
// app/products/[id]/add-to-cart-button.tsx
'use client'
import { useState } from 'react'
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, setPending] = useState(false)
async function handleClick() {
setPending(true)
// 调用 Server Action 或客户端请求接口
setPending(false)
}
return (
<button disabled={pending} onClick={handleClick}>
{pending ? '加入中...' : '加入购物车'}
</button>
)
}
2. Effect 与浏览器 API
涉及 useEffect、window、document、localStorage、地理位置、媒体设备、拖拽、剪贴板等浏览器能力时,组件必须在客户端。
地图、富文本编辑器、依赖 DOM 测量的图表、虚拟滚动列表等第三方组件,通常也属于这一类。
3. 持续同步的交互状态
首屏数据适合服务端读取,但以下需求往往需要客户端数据层:
- 用户修改搜索筛选条件后的即时刷新;
- 评论列表的分页加载与乐观发布;
- 实时通知、WebSocket 订阅或轮询;
- 看板图表的时间范围切换、自动刷新和局部 loading 状态;
- 购物车数量、草稿内容、未提交表单等短生命周期状态。
注意:这不代表整个页面要转为客户端。更常见的结构是:服务端渲染初始视图,客户端组件只接管会持续变化的局部区域。
最重要的边界技巧:把 use client 下沉到叶子组件
'use client' 不是给某个组件"增加交互能力"的普通标记;它定义了服务端模块图与客户端模块图之间的边界。一个文件成为客户端入口后,它静态导入的依赖会进入客户端模块图;由该组件直接导入并渲染的子组件也必须兼容客户端环境。服务端组件可以作为服务端父组件创建的 JSX,通过 children 等 props 传入客户端组件。
因此,下面的写法通常代价过高:
tsx
// 不推荐:整个页面及其导入树都被推向客户端
'use client'
export default function ProductPage() {
// 页面读取、详情展示、推荐、评论、按钮全在客户端树中
}
更好的结构是:
text
ProductPage Server Component
├── ProductDetails Server Component
├── RecommendedProducts Server Component
├── Reviews Server Component / Suspense 边界
└── AddToCartButton Client Component
这样,只有按钮及其确实依赖的交互代码进入客户端包。
客户端外壳不必吞掉服务端内容
"弹窗、抽屉、Tabs 都需要状态,所以内容必须客户端化"是常见误解。
客户端组件可以管理开关状态,同时接收由服务端父组件传入的 children:
tsx
// app/ui/cart-drawer.tsx
'use client'
import { useState } from 'react'
export function CartDrawer({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>查看购物车</button>
{open ? <aside>{children}</aside> : null}
</>
)
}
tsx
// Server Component
import { CartDrawer } from '@/app/ui/cart-drawer'
import { CartContents } from '@/app/ui/cart-contents'
export default function Header() {
return (
<CartDrawer>
<CartContents />
</CartDrawer>
)
}
这里 CartDrawer 是客户端交互外壳,但 CartContents 仍可作为服务端内容完成数据读取和预渲染。边界的重点不是父子文件关系,而是由谁创建和传递这段 JSX。
真实模块怎么划分
| 模块 | 优先留在服务端的部分 | 必须或通常放客户端的部分 | 写入与安全位置 |
|---|---|---|---|
| 商品详情页 | 商品、库存、价格、相关推荐、权限可见性 | 图片轮播、规格选择、加入购物车按钮 | 下单与加购在 Server Action 或服务端接口中校验用户、库存和价格 |
| 文章页 | 正文、作者、目录、服务端评论首屏 | 点赞、收藏、评论输入、局部排序切换 | 点赞和评论提交在服务端重新鉴权与校验 |
| 搜索筛选 | 初始结果、可索引内容、服务端过滤规则 | 输入框、防抖、筛选状态、URL 交互 | 复杂查询可通过 Route Handler 或服务端导航处理 |
| 购物车 | 初始购物车快照、价格计算、促销规则 | 数量增减、删除动画、乐观 UI | 服务端作为价格、优惠和库存的最终裁决者 |
| SaaS 看板 | 首屏指标、组织数据裁剪、权限过滤 | 日期选择、图表 hover、自动刷新、局部轮询 | 所有组织与资源访问均在服务端验证 |
| 评论区 | 首屏评论、用户可见范围 | 发布、回复、展开折叠、乐观插入 | 服务端校验身份、频率、内容和资源权限 |
| 富文本编辑器 | 已发布内容、草稿初始加载、文档权限 | 编辑器内核、选区、快捷键、协同编辑状态 | 保存、发布、版本控制在服务端处理 |
| 地图或重型图表 | 初始聚合数据、权限过滤、地理数据裁剪 | 地图 SDK、缩放拖拽、hover、客户端渲染 | 服务端限制数据范围,必要时提供受控查询端点 |
这张表背后的规律是:初始内容、私密数据和业务裁决靠近服务端;用户连续操作形成的瞬时状态靠近客户端。
数据获取:先在服务端读取,再为"持续变化"增加客户端机制
一个实用的分层方式是:
- Server Component 获取首屏数据,并完成权限过滤与 DTO 裁剪。
- Client Component 接收初始数据,管理输入、选择、展开、乐观状态等本地交互。
- 当客户端需要主动刷新、分页、轮询或订阅时,再调用 Route Handler、后端 BFF 或其他受控服务端接口。
- 写操作完成后,由 Server Action 或服务端接口执行校验、写入和缓存失效;客户端据此更新局部 UI 或重新拉取数据。
服务端读取并不自动避免性能问题。仍要检查:
- 多个独立请求是否被串行
await,造成服务端数据瀑布; - 是否应该并行发起请求;
- 慢数据是否应该放在靠近数据访问位置的
<Suspense>边界中流式返回; - 数据是否需要缓存,以及缓存的新鲜度和失效路径是否明确;
- 是否对同一请求中的重复读取使用请求级去重,例如
fetch的请求记忆化或React.cache。
服务端组件的优势是减少浏览器到服务端之间不必要的往返,而不是允许忽略数据依赖设计。
Server Actions、Route Handlers 与 RSC:不要因为都在服务端就混用
三者都可在服务端运行,但服务边界不同。
RSC:读取并组装 UI
Server Components 最适合页面数据读取、服务端聚合、权限过滤,以及生成 UI 结构。它们应直接调用领域服务、ORM 或后端服务,而不是绕经本应用的 HTTP Route Handler。
Server Actions:由用户操作触发的受控写入
Server Actions 适合表单提交和由客户端事件发起的 mutation,例如创建评论、更新资料、加入购物车、保存草稿。
它们的价值在于与 React UI 更新流程结合,但不能因此被当成"内部调用,所以天然安全"。每个 Action 都应验证输入、读取当前用户,并在服务端确认其对目标资源有操作权限。
Route Handlers:明确的 HTTP 边界
Route Handlers 适合以下情形:
- 客户端需要以 HTTP 方式获取或刷新数据;
- 外部系统、Webhook、移动端或第三方调用方需要接口;
- 你需要自定义 Request/Response、响应头、流式协议或 Web 标准接口。
它们应按公开 API 端点的标准处理:鉴权、授权、输入验证、限流和错误处理都不能省略。
跨边界传参:设计 DTO,不要传服务对象
Server Component 传给 Client Component 的 props 必须能够被 React 序列化。普通字符串、数字、布尔值、数组、普通对象,以及 React 支持的部分内置类型可以跨边界;但不要把以下内容当作普通 props 传递:
- 任意普通函数;
- 数据库连接、ORM 查询构造器、服务实例;
- 自定义类实例;
- 包含不可序列化值的复杂上下文对象;
- 意外暴露敏感字段的完整数据库记录。
更好的做法是定义面向 UI 的 DTO:
ts
type ProductCardDTO = {
id: string
name: string
price: string
imageUrl: string
canPurchase: boolean
}
客户端拿到的是渲染和交互所需的最小数据,而不是领域对象本身。若确实需要让客户端触发服务端函数,应使用框架支持的 Server Function / Server Action 机制,而不是尝试把任意闭包跨边界传递。
Provider 与第三方库:最常见的客户端边界扩散源
全局状态库、主题 Provider、国际化 Provider 和 UI 组件库经常依赖 Context、state 或 Effect。它们会推动树的一部分进入客户端。
处理原则有两个:
- 为不兼容 RSC 的第三方组件提供本地客户端包装器。 如果第三方包内部使用 Hooks,却没有正确声明客户端入口,不要直接在 Server Component 中引用;用一个很薄的本地 Client Component 包装它。
- 把 Provider 放得尽可能深。 不要为了一个局部交互就用客户端 Provider 包裹整个根布局。Provider 覆盖范围越小,保留为服务端的静态区域越多。
所谓"设计系统必须全部客户端化"并不成立。按钮的可点击变体、弹窗控制器、日期选择器可能是客户端组件;纯排版容器、静态卡片、服务端渲染的文本与图文结构仍可以是服务端组件。
常见反模式与修复方式
反模式一:在根布局或页面顶层滥用 'use client'
后果:大量本可留在服务端的依赖进入浏览器包,数据访问被迫转向客户端,请求链变长。
修复:删除顶层标记,将状态和事件下沉至按钮、筛选器、抽屉、编辑器等最小交互单元。
反模式二:Server Component 为读取数据调用自己的 Route Handler
后果:增加 HTTP 往返;构建期或预渲染时还可能失败。
修复:抽取共享的数据访问层,由 Server Component 直接调用;Route Handler 仅承担 HTTP 调用边界。
反模式三:为了"组件复用"把整棵树改为客户端
后果:复用的是组件形式,牺牲的是服务端读取能力、包体积和安全边界。
修复 :拆分为服务端展示组件与客户端交互增强组件;使用 children、插槽或明确 DTO 组合,而不是让一个万能组件承担所有环境。
反模式四:只在客户端做权限判断
后果:用户可以绕过 UI,直接请求接口、Action 或其他入口。
修复:在数据访问层、Server Action 和 Route Handler 中分别执行授权检查;客户端显隐仅用于体验优化。
反模式五:把"服务端"当成性能万能药
后果:慢查询、串行请求、没有 Suspense 边界、错误缓存策略,仍会阻塞页面并提高服务端成本。
修复:同时审查并行请求、缓存策略、流式边界、失效机制和真实交互响应。
发布前的组件边界检查清单
每次新增 'use client' 时,问自己:
- 这个文件是否真的需要事件、状态、Effect、浏览器 API 或客户端 Hook?
- 能否只把更小的子组件改为客户端?
- 这个边界会把哪些依赖一并带入客户端包?
- 服务端内容能否通过
children或插槽继续保留在服务端?
每次读取数据时,问自己:
- 这是首屏读取,还是用户持续驱动的数据同步?
- Server Component 能否直接访问数据源,而不是绕行 Route Handler?
- 是否存在串行数据瀑布、重复读取或不必要的客户端往返?
- 缓存、失效和慢数据的 Suspense 边界是否明确?
每次执行写操作或权限控制时,问自己:
- Server Action 或 Route Handler 是否独立验证身份、输入与资源权限?
- 客户端是否只拿到了完成 UI 所需的最小数据?
- 即使绕过页面 UI,服务端是否仍能拒绝未授权操作?
结论
RSC 的正确边界不是"服务端组件越多越好",而是让每段代码运行在它唯一需要、也最适合运行的环境中。
在 Next.js App Router 中,最稳妥的默认策略是:页面、布局、数据读取、权限过滤和静态展示先留在服务端;把事件、局部状态、浏览器 API、富交互与持续同步下沉到最小 Client Component。
当边界需要扩大时,先检查能否使用服务端内容插槽、DTO、客户端包装器和更深层的 Provider;当数据需要变化时,再清晰地区分首屏读取、客户端刷新、服务端写入与 HTTP 接口。这样得到的不是一张"哪些组件绝对该放哪里"的静态表,而是一套能随真实业务演进的架构决策方法。