React Server Components 边界:不是所有组件都该搬到服务端
React Server Components 带来了新的架构选择。服务端组件可以减少客户端包体、直接访问后端资源,也能让一些页面更快。但它不是"把所有组件搬到服务端"的按钮。交互状态、浏览器 API、动画、实时输入,这些仍然属于客户端边界。
RSC 的关键不是追新,而是把数据读取和交互状态放在合适的位置。
一、先区分数据组件和交互组件
flowchart TD A[Component] --> B[Data Fetching] A --> C[Interactive State] B --> D[Server Component] C --> E[Client Component]展示型、数据读取型组件适合服务端;按钮交互、表单状态、拖拽、动画控制更适合客户端。
判断标准可以细化为一个决策树:
这个组件需要什么? ├── 只读取数据并渲染 → Server Component ├── 需要 useState / useEffect → Client Component ├── 需要 onClick / onScroll / 其他事件 → Client Component ├── 需要 browser API (localStorage, window, navigator) → Client Component ├── 大部分是 Server,只有一个小交互 → 拆成 Container (Server) + Island (Client) └── 不确定 → 先写成 Client,后续优化一个常见误区是把"需要动态数据的组件"全都写成 Client Component。实际上,服务端组件完全可以 async 读取数据库或 API:
// Server Component — 直接读数据库 export default async function UserProfile({ userId }: { userId: string }) { const user = await db.user.findUnique({ where: { id: userId } }); if (!user) return <NotFound />; return <ProfileCard user={user} />; }但如果ProfileCard内部有编辑按钮、弹窗、表单,那它应该是 Client Component,接收 user 数据作为 props。
二、服务端组件适合读数据
export default async function ProductList() { const products = await getProducts(); return <ProductGrid products={products} />; }这种组件不需要把请求逻辑和数据依赖打进浏览器。用户拿到的是渲染结果,客户端 JS 更少。
更进一步的实践是组合 Server Component + Client Islands:
// page.tsx — Server Component export default async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id); const reviews = await getReviews(params.id); return ( <div> <ProductDetail product={product} /> <AddToCartButton productId={product.id} price={product.price} /> <ReviewList reviews={reviews} /> </div> ); }ProductDetail和ReviewList是 Server Component,直接渲染数据。AddToCartButton是 Client Component('use client'),负责加购交互。页面框架是 Server,交互是 Island。
这种模式的最大收益不是包体,而是数据请求的简化。Server Component 可以直接 await 多个数据源,不需要在前端写useEffect+fetch+loading/error的状态管理。数据怎么来、怎么组合,全在服务端决定,前端只负责渲染。
踩坑:Server Component 中 async 读取数据虽然优雅,但要注意数据库连接的释放时机。如果getProduct内部使用了连接池但没有在请求结束后归还连接,多个并行请求可能导致连接池耗尽。尤其是Promise.all([getProduct(id), getReviews(id)])这种并行请求,每个 async component 函数内部维护自己的连接获取逻辑,容易产生"连接泄漏但看不出报错"的问题。
// 推荐:在 Server Component 的数据获取函数中明确管理连接生命周期 export default async function ProductPage({ params }: { params: { id: string } }) { // 一次查询获取所有需要的数据,减少连接占用 const { product, reviews } = await getProductWithReviews(params.id); return ( <div> <ProductDetail product={product} /> <AddToCartButton productId={product.id} price={product.price} /> <ReviewList reviews={reviews} /> </div> ); }还有一个容易被忽略的场景:SEO 依赖的内容。搜索引擎爬虫可以执行 JS,但渲染依赖不稳定的客户端数据会降低收录率。把 SEO 关键内容放在 Server Component 中,返回静态 HTML,搜索引擎能稳定索引。
三、客户端组件负责交互
'use client'; export function SearchBox() { const [keyword, setKeyword] = useState(''); return <input value={keyword} onChange={e => setKeyword(e.target.value)} />; }一旦组件需要useState、useEffect、浏览器事件,就要成为客户端组件。不要为了"服务端优先"把交互写得别扭。
场景描述:我们曾试图把搜索页的SearchBox和SearchResults都写成 Server Component,搜索触发时通过 URL 参数传递关键词,刷新页面来展示结果。技术上可行,但用户输入过程中的即时建议、防抖、历史搜索等功能全部丧失。更糟糕的是,每次搜索都是一次完整的页面刷新——在移动端网络下,这个体验等同于回到了 PHP 时代。
教训:即时交互组件不要去服务端。SearchBox应该保持 'use client',但SearchResults可以拆成两半:搜索结果列表部分如果是基于关键词的纯数据展示,可以放在 Server Component 中通过 URL 参数接收;但搜索过程中的骨架屏、加载态、错误重试等仍需要客户端处理。关键不是"把所有东西放一个端",而是"每个组件找到最适合它的端"。
四、边界要避免频繁穿越
服务端和客户端组件混用时,props 必须可序列化。函数、复杂实例不能随便传。
rsc_boundary_check: serializable_props: required no_browser_api_in_server: true client_component_minimized: true如果一个页面边界切得太碎,理解成本会上升。RSC 应该让结构更清楚,不是更绕。
Props 序列化是 RSC 核心约束之一。Server Component 传递给 Client Component 的 props 必须是可 JSON 序列化的。不能传函数、类实例、Symbol、BigInt。
场景描述:我们在一个商品详情页中,Server Component 读取了商品数据后,想直接传递product.calculateDiscount()方法给AddToCartButton——这是一个类方法,JSON 序列化时会丢失。结果AddToCartButton中调用discount()时得到一个 undefined,但没有任何控制台报错或编译期提示,因为 TypeScript 把它的类型声明为了() => number,实际上运行时它根本不存在。
// 踩坑:Server Component 传递了不可序列化的方法 <ProductCard product={product} /> // product 是 class instance // 传输后 product.calculateDiscount 丢失 // 正确:将计算结果作为普通值传递 const discountPrice = product.calculateDiscount(); <ProductCard productData={{ name, price, discountPrice }} />这个问题的排查成本很高——它不会在开发阶段报错,只会在生产环境的 hydration 失败或功能异常中暴露。因此建议在项目中配置 ESLint 规则,禁止 Server Component 直接向 Client Component 传递非可序列化的 props 类型。
数据缓存也要一起考虑。服务端组件读取数据时,要明确是静态、按请求还是按标签失效。否则页面看起来迁到了服务端,实际缓存策略却不清楚。
rsc_data_policy: static_content: cache user_dashboard: no_store_or_session_cache product_list: revalidate_by_tagRSC 的性能收益常常来自少发 JS 和合理缓存。只迁组件,不设计数据策略,收益会打折。
踩坑:我们上线 RSC 版本的 Dashboard 后发现,首页加载速度反而变慢了。排查发现,Dashboard 页面并行调用了 12 个数据源的fetch,但 Next.js 默认的fetch缓存互相独立,导致每次请求都触发数据库查询。改用 React 的cache()包装去重后,相同请求的数据只查一次,响应时间从 2.3 秒降到了 800ms。
// 使用 React cache() 去重请求 import { cache } from 'react'; const getUser = cache(async (id: string) => { return db.user.findUnique({ where: { id } }); }); // 同一请求周期内多次调用 getUser('123') 只会执行一次数据库查询五、总结
React Server Components 适合数据读取和减少客户端包体,但交互状态、浏览器 API 和即时反馈仍应留在客户端组件。
不是所有组件都该搬到服务端。边界清楚,RSC 才能提升体验,而不是增加心智负担。
评估时可以看客户端包体、首屏数据等待、交互延迟和代码复杂度。四个指标一起看,比单纯追逐新架构稳得多。
迁移也应该从边界清楚的页面开始。比如文档页、商品详情、只读报表,通常比复杂编辑器更适合先试。先用低风险页面验证构建、缓存和部署链路,再考虑核心交互页面。
rsc_migration_order: docs_page product_detail dashboard_readonly complex_editor_last架构升级不要一上来挑战最复杂页面,容易把技术评估变成事故演练。