React Server Components 在真实项目中的边界:哪些组件该放在服务端

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.tsxlayout.tsx 默认是 Server Components。首次加载一个路由时,Next.js 会在服务端生成 HTML 预览和 RSC Payload,并提供 Client Components 所需的 JavaScript;浏览器随后为交互部分完成 hydration。

因此:

  • Server Component:组件逻辑运行在服务端环境,可直接访问数据库、内部服务、密钥或请求上下文;其组件实现不会作为客户端交互 JavaScript 发送给浏览器。
  • Client Component:能使用事件处理、状态、Effect、浏览器 API 等客户端能力;它不意味着首次加载时完全不参与服务端生成 HTML。
  • SSR:描述的是服务端预先生成 HTML 的渲染过程;RSC 则进一步定义了哪些组件逻辑可以留在服务端,以及客户端与服务端组件如何组合。

把它理解为两层分工会更准确:服务端负责读取、裁剪和组装首屏内容;客户端负责用户操作之后的局部交互与持续变化。

一句话决策规则:先问"是否必须在浏览器运行"

为一个组件决定归属时,按下面顺序判断。

  1. 它是否需要事件、状态、Effect 或浏览器 API?
    • 是:它或其最小交互子树必须是 Client Component。
    • 否:继续判断。
  2. 它是否需要数据库、内部 API、密钥、Cookie、请求头或服务端权限判断?
    • 是:优先是 Server Component。
  3. 它只是一个交互容器,内部内容并不需要客户端能力吗?
    • 是:容器可为 Client Component,但内容可以继续由服务端渲染后以 children 或插槽形式传入。
  4. 数据是首屏读取,还是需要用户驱动刷新、实时订阅、轮询或乐观更新?
    • 首屏读取:优先服务端。
    • 持续变化:在客户端交互叶子中引入相应的数据机制,并将写入或敏感读取保留在服务端接口。
  5. 跨边界传入的数据能否序列化?
    • 不能:重构边界、传递普通数据或 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. 事件处理和局部状态

onClickonChangeuseStateuseReducer 等能力需要浏览器中的 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

涉及 useEffectwindowdocumentlocalStorage、地理位置、媒体设备、拖拽、剪贴板等浏览器能力时,组件必须在客户端。

地图、富文本编辑器、依赖 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、客户端渲染 服务端限制数据范围,必要时提供受控查询端点

这张表背后的规律是:初始内容、私密数据和业务裁决靠近服务端;用户连续操作形成的瞬时状态靠近客户端。

数据获取:先在服务端读取,再为"持续变化"增加客户端机制

一个实用的分层方式是:

  1. Server Component 获取首屏数据,并完成权限过滤与 DTO 裁剪。
  2. Client Component 接收初始数据,管理输入、选择、展开、乐观状态等本地交互。
  3. 当客户端需要主动刷新、分页、轮询或订阅时,再调用 Route Handler、后端 BFF 或其他受控服务端接口。
  4. 写操作完成后,由 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。它们会推动树的一部分进入客户端。

处理原则有两个:

  1. 为不兼容 RSC 的第三方组件提供本地客户端包装器。 如果第三方包内部使用 Hooks,却没有正确声明客户端入口,不要直接在 Server Component 中引用;用一个很薄的本地 Client Component 包装它。
  2. 把 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 接口。这样得到的不是一张"哪些组件绝对该放哪里"的静态表,而是一套能随真实业务演进的架构决策方法。

参考资料

相关推荐
OpenTiny社区2 小时前
GenUI SDK v1.3.0 开发者深度解读:当生成式 UI 开始"长出"工程化骨架
前端·ai编程
前端粉刷匠2 小时前
2025 年是 Agent 的,2026 年是 Harness 的——AI 编程 Harness 架构深度解析
前端·人工智能
张元清2 小时前
React useSessionStorage Hook:刷新不丢、只属于当前标签页的状态 (2026)
前端·javascript·react.js
cindershade2 小时前
为 TypeScript 项目建立可靠的类型边界:API 响应、表单与第三方库
前端
半仙er2 小时前
第一周02天 原型与原型链
前端
半仙er2 小时前
第一周04天Promise 深入与 async / await
前端
fail_to_code2 小时前
从 Lighthouse 83 到 100:一次 Vue 项目的性能排查实录
前端·人工智能
用户69371750013842 小时前
DeepSeek 调价正式生效:一夜涨 11 倍,靠低价薅羊毛的日子结束了
前端·人工智能·后端
半仙er3 小时前
第一周01天:this 指向与 call / apply / bind
前端