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

发布时间:2026/8/17 2:00:05
React Server Components 在真实项目中的边界:哪些组件该放在服务端 原文链接React Server Components 在真实项目中的边界哪些组件该放在服务端React Server ComponentsRSC最容易引发两种极端做法要么把整个页面写成 Client Component沿用传统 React 的习惯要么误以为“服务端组件更快”于是试图把所有逻辑都塞进服务端。真实项目中组件归属不应由“它是不是一个页面”或“它有没有一点交互”决定。更可靠的原则是默认放在服务端只有确实依赖浏览器、交互状态或客户端生命周期的最小子树才划为客户端边界。本文以Next.js App Router为主要落地语境。RSC 是 React 的架构能力但路由、缓存、Server Actions 和部署运行时的具体行为仍取决于框架实现。先纠正一个概念RSC 不等于 SSRClient 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或插槽形式传入。数据是首屏读取还是需要用户驱动刷新、实时订阅、轮询或乐观更新首屏读取优先服务端。持续变化在客户端交互叶子中引入相应的数据机制并将写入或敏感读取保留在服务端接口。跨边界传入的数据能否序列化不能重构边界、传递普通数据或 JSX 插槽而不是试图把函数、类实例或复杂服务对象直接传给客户端。核心不是“交互组件必须少”而是客户端代码的覆盖范围必须与它真实需要的浏览器能力一致。哪些组件应优先放在服务端以下是强烈的服务端信号。1. 页面、布局与大多数展示层页面骨架、导航结构、文章正文、商品信息、价格说明、服务端生成的 SEO 内容以及不需要本地状态的展示组件通常都应保留为 Server Components。这样做的直接收益不是抽象意义上的“更先进”而是这些组件及其依赖不会被无谓地打进浏览器包。页面可以在服务端完成数据读取和结构拼装浏览器只接收真正需要交互的代码。2. 直接读取数据库或内部服务服务端组件可以直接调用 ORM、数据库客户端或内部领域服务。以下示例采用 Next.js 当前文档中常见的异步params写法若项目仍使用较早版本的 Next.js应按该版本的路由参数类型调整。// 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 运行时。// 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 传入客户端组件。因此下面的写法通常代价过高// 不推荐整个页面及其导入树都被推向客户端 use client export default function ProductPage() { // 页面读取、详情展示、推荐、评论、按钮全在客户端树中 }更好的结构是ProductPage Server Component ├── ProductDetails Server Component ├── RecommendedProducts Server Component ├── Reviews Server Component / Suspense 边界 └── AddToCartButton Client Component这样只有按钮及其确实依赖的交互代码进入客户端包。客户端外壳不必吞掉服务端内容“弹窗、抽屉、Tabs 都需要状态所以内容必须客户端化”是常见误解。客户端组件可以管理开关状态同时接收由服务端父组件传入的children// 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} / ) }// 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读取并组装 UIServer 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 的 DTOtype 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 接口。这样得到的不是一张“哪些组件绝对该放哪里”的静态表而是一套能随真实业务演进的架构决策方法。参考资料ReactServer ComponentsReactuse client与可序列化 propsNext.jsServer and Client ComponentsNext.jsFetching DataNext.jsAuthenticationNext.jsBackend for Frontend