React Server Components 的数据边界怎么设计

0阅读12分钟

一个让我重写整个页面布局的边界问题

上个月我在做一个商品详情页的重构。页面结构不复杂:顶部是商品图和基础信息,中间是规格选择器,底部是用户评价列表和推荐商品。第一版代码我写得很顺手——把整个页面作为一个 Server Component,数据在里面直接 await 数据库查询,组件树从外到内一气呵成。

直到产品经理要求在规格选择器里加一个「实时价格联动」:用户选了颜色和内存之后,价格、库存状态和优惠信息要实时更新。

这个需求意味着规格选择器必须有 useState,有 useState 就必须标记 'use client'。我在这个组件前面加了 'use client',整个页面突然之间 hydration 报错——因为这个组件的 props 里传了一个从 Prisma 查出来的 ORM 对象,里面有 Date 实例、Decimal 类型,还有循环引用的关联关系。React 在序列化边界上直接罢工了。

问题不在规格选择器,也不在数据库查询。问题出在我没有认真对待 Server Component 和 Client Component 之间那条看不见的数据边界。这条边界不是 import 语句,不是文件路径,而是 React 在运行时用 Flight 协议维护的一条序列化鸿沟。跨过去的数据必须是纯 JSON 可序列化的,任何函数、类实例、Symbol 都会在这条线上被拦截。

这篇文章记录的是我从那次翻车之后,逐步整理出来的 RSC 数据边界设计方法。不是理论推导,而是三次具体的翻车经历和对应的修复策略。

RSC 的本质是一条序列化边界

React 官方文档对 Server Components 的定义很克制:「Server Components 是一种新类型的组件,它们在打包之前、在一个与客户端应用或 SSR 服务器分离的环境中提前渲染。」这个定义里没有提到「把组件搬到服务端」,也没有暗示所有组件都应该变成 Server Component。

RSC 真正引入的核心概念是模块依赖树边界。Dan Abramov 在 2020 年底的 RFC 里用了一个精确的类比:这不是 C/S 架构里的网络边界,而是模块系统里的执行环境分界线。'use client' 指令标记的不是一种组件类型,而是模块依赖树上的一条分界线——这条线以上的模块在服务端执行,以下的模块在浏览器执行。

流程图画布 · 115%
Mermaid 流程图加载中...

React 团队在 2023 年发布的 Flight 协议规范里进一步明确了这条边界的技术实现:Server Component 的渲染结果不是 HTML,而是一个 JSON 格式的虚拟组件树描述(RSC Payload)。这个 Payload 通过流式传输到达客户端,由 React 的客户端运行时负责解析和挂载。在这条传输链上,每一个跨越边界的值都要经过 JSON.stringify 的考验。

Andrew Clark 在 React Conf 2023 的演讲中提到一个关键判断标准:「如果你不确定一个组件应该放在哪一边,先问数据从哪里来。」数据来自服务端(数据库、文件系统、内部 API),就留在 Server Component;数据来自用户交互(输入、点击、拖拽、浏览器 API),就放在 Client Component。

维度Server ComponentClient Component
执行环境Node.js / Edge Runtime浏览器
数据获取直接 async/await通过 Server Actions 或 API 调用
状态管理无状态(每次请求重新渲染)useStateuseReduceruseEffect
事件处理不支持支持所有合成事件
可传递类型任意(包括类实例、函数)仅 JSON 可序列化类型
对 bundle 的影响零客户端 JS计入客户端 bundle
SEO 友好性天然支持需要 SSR 配合
典型用途数据展示、布局、元数据、列表页表单、弹窗、拖拽、实时预览、浏览器 API

翻车一:ORM 对象穿越序列化边界

这是我最初遇到的那个问题。商品详情页的 Server Component 直接将从 Prisma 查出来的 product 对象透传给了一个标记了 'use client' 的规格选择器组件。Prisma 返回的对象里有 Decimal 类型(价格字段)、Date 实例(创建时间)和关联关系的懒加载代理。这些都不是合法的 JSON 值。

React 在开发模式下会给出一个黄色警告:「Only plain objects can be passed to Client Components from Server Components.」但在生产模式下,这个警告被静默处理,结果是客户端拿到的 Decimal 对象变成了空对象 {},价格显示为 NaN

坏的做法

// page.tsx - Server Component
import { prisma } from '@/lib/db'
import { SpecSelector } from './_components/spec-selector' // 'use client'
 
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await prisma.product.findUnique({
    where: { id: params.id },
    include: { specs: true, variants: true }
  })
 
  // ❌ 直接把 ORM 对象传给 Client Component
  return <SpecSelector product={product} />
}
// _components/spec-selector.tsx - Client Component
'use client'
 
import { useState } from 'react'
 
// ❌ product 里包含 Decimal、Date、循环引用
export function SpecSelector({ product }: { product: Product }) {
  const [selected, setSelected] = useState<string[]>([])
  const price = product.price // Decimal 对象,序列化后变成 {}
 
  return (
    <div>
      <span{price.toString()}</span> {/* 显示 NaN */}
      {/* ...规格选择 UI */}
    </div>
  )
}

修复方案:在边界上做一次显式的数据塑形

Server Component 的职责不仅是获取数据,还要把数据「翻译」成 Client Component 能理解的纯数据结构。这个翻译动作就是边界上的数据塑形(Data Shaping)。

// page.tsx - Server Component
import { prisma } from '@/lib/db'
import { SpecSelector } from './_components/spec-selector'
 
// 在边界上做数据塑形,把 ORM 对象转成纯 JSON 结构
function shapeProductForClient(product: any) {
  return {
    id: product.id,
    name: product.name,
    price: Number(product.price),      // Decimal → number
    createdAt: product.createdAt.toISOString(), // Date → string
    specs: product.specs.map((s: any) => ({
      id: s.id,
      name: s.name,
      options: s.options              // 假设 options 是 JSON 字段,已经是纯对象
    })),
    variants: product.variants.map((v: any) => ({
      id: v.id,
      sku: v.sku,
      price: Number(v.price),
      stock: v.stock,
      specValueIds: v.specValueIds    // 纯数组
    }))
  }
}
 
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await prisma.product.findUnique({
    where: { id: params.id },
    include: { specs: true, variants: true }
  })
 
  // ✅ 传递纯数据对象
  return <SpecSelector product={shapeProductForClient(product)} />
}
// _components/spec-selector.tsx - Client Component
'use client'
import { useState } from 'react'
 
interface ProductDTO {
  id: string
  name: string
  price: number           // ✅ 纯 number
  createdAt: string       // ✅ ISO 字符串
  specs: Array<{ id: string; name: string; options: any }>
  variants: Array<{ id: string; sku: string; price: number; stock: number; specValueIds: string[] }>
}
 
export function SpecSelector({ product }: { product: ProductDTO }) {
  const [selected, setSelected] = useState<string[]>([])
  // ✅ 直接可用,不需要额外转换
  const price = product.price
 
  return (
    <div>
      <span{price.toFixed(2)}</span>
      {/* ...规格选择 UI */}
    </div>
  )
}

数据塑形这一层代码看起来是多余的——它确实在做字段映射——但它解决的不仅仅是序列化问题。它同时建立了 Server 和 Client 之间的类型契约。ProductDTO 接口就是这条边界的合同,双方都按合同办事,不会出现「服务端觉得传了、客户端觉得没收到」的情况。

「use client」不是组件标签,是模块依赖树的切割线

很多团队(包括我最初的理解)会把 'use client' 当成一种组件标签,类似于「这个组件是客户端渲染的」。这种理解会导致一个常见的错误模式:在组件树的中层放一个 'use client' 组件,然后期望它内部的所有子组件都自动变成 Client Component。

React 的模块系统不是这样工作的。'use client' 标记的是文件,不是组件。一个文件里如果写了 'use client',那么这个文件 import 的所有模块都会在客户端打包和执行。但这些模块里定义的组件,如果它们来自另一个 Server Component 文件,仍然会在服务端渲染——前提是它们作为 children 或 slot 传入,而不是被直接 import

这条规则的出处是 React 官方文档对 'use client' 指令的说明:「'use client' defines a boundary between server-side and client-side code. It should be placed at the top of files that need to run in the browser.」注意关键词是「files」,不是「components」。

坏的做法:把 Client 组件当容器用

// app/dashboard/page.tsx - Server Component
import { DashboardShell } from './_components/dashboard-shell' // 'use client'
import { StatsChart } from './_components/stats-chart'         // Server Component
 
export default async function DashboardPage() {
  const stats = await fetchStats()
 
  // ❌ DashboardShell 是 'use client',它 import 了 StatsChart
  // StatsChart 会被拉进客户端 bundle,即使它本身不需要交互
  return (
    <DashboardShell>
      <StatsChart data={stats} />
    </DashboardShell>
  )
}
// _components/dashboard-shell.tsx
'use client'
import { useState } from 'react'
// ❌ 在 Client 文件里 import Server Component
// StatsChart 及其所有依赖都会被打包进客户端
import { StatsChart } from './stats-chart'
import { RecentOrders } from './recent-orders'
 
export function DashboardShell({ children }: { children: React.ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(true)
  return (
    <div>
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>菜单</button>
      {/* ❌ 直接 import 的组件全部变成客户端代码 */}
      <StatsChart data={...} />
      <RecentOrders />
      {children}
    </div>
  )
}

修复方案:用 children slot 保持边界清晰

// _components/dashboard-shell.tsx
'use client'
import { useState } from 'react'
 
// ✅ 只 import 真正需要客户端交互的东西
export function DashboardShell({ children }: { children: React.ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(true)
  return (
    <div>
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>菜单</button>
      {/* ✅ children 由父级(Server Component)决定内容 */}
      {children}
    </div>
  )
}
// app/dashboard/page.tsx - Server Component
import { DashboardShell } from './_components/dashboard-shell'
import { StatsChart } from './_components/stats-chart'
import { RecentOrders } from './_components/recent-orders'
 
export default async function DashboardPage() {
  const stats = await fetchStats()
  const orders = await fetchOrders()
 
  // ✅ StatsChart 和 RecentOrders 保持在服务端渲染
  // 它们作为 children 传入 DashboardShell,但不会被拉进客户端 bundle
  return (
    <DashboardShell>
      <StatsChart data={stats} />
      <RecentOrders orders={orders} />
    </DashboardShell>
  )
}

这个模式在 React 团队内部被称为「Server Component as children slot」。核心思路是:Client Component 只负责交互外壳(布局切换、弹窗控制、拖拽容器),具体的数据展示内容通过 children 或具名 slot 从外部注入,注入的内容仍然在服务端渲染。

传递方式Client 组件内部行为对 bundle 的影响
直接 import被拉进客户端 bundle❌ 增加客户端 JS 体积
作为 children 传入保留在服务端渲染✅ 零客户端 JS
作为具名 slot(slots.header保留在服务端渲染✅ 零客户端 JS
通过 cloneElement 注入保留在服务端渲染✅ 零客户端 JS

翻车二:函数回调穿越边界的三种失败模式

序列化边界上最常见的冲突是函数传递。Server Component 想把一个数据提交的处理函数传给 Client Component,Client Component 在用户点击时调用它。在传统的 React 开发里,这只是一个普通的 props 传递。在 RSC 的世界里,函数不能跨越序列化边界。

这个问题有三种典型的失败模式,我各自踩过一次。

模式一:直接传回调函数。 Server Component 定义一个普通函数传给 Client Component,React 在序列化时抛出错误。

模式二:用 Server Action 但没有理解它的异步本质。 把 Server Action 传给 Client Component 的 onClick,然后在组件里用 await 处理返回值,但没有处理好 loading 状态和错误回退,导致用户点了按钮之后页面毫无反应。

模式三:把 Server Action 和客户端状态更新混在一起。 在 Client Component 里调用 Server Action 修改了服务端数据,然后试图用返回值直接更新本地的 useState,但返回值也是一个需要序列化的对象,又碰到了类型不匹配的问题。

坏的做法:回调函数直传

// page.tsx - Server Component
import { CommentForm } from './_components/comment-form' // 'use client'
 
export default async function ArticlePage() {
  const article = await fetchArticle()
 
  // ❌ handleSubmit 是函数,不能跨越序列化边界
  async function handleSubmit(content: string) {
    'use server'
    await db.comment.create({ data: { content, articleId: article.id } })
  }
 
  return <CommentForm onSubmit={handleSubmit} />
}

修复方案:Server Action 独立模块 + 客户端状态分离

// actions/comment-actions.ts
'use server'
 
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
 
// ✅ Server Action 独立成模块,在 Client Component 中 import 使用
export async function submitComment(formData: FormData) {
  const content = formData.get('content') as string
  const articleId = formData.get('articleId') as string
 
  if (!content || content.length > 2000) {
    return { success: false, error: '评论内容不合法' }
  }
 
  try {
    await db.comment.create({
      data: { content, articleId }
    })
    // ✅ 用 revalidatePath 触发服务端数据刷新
    revalidatePath(`/article/${articleId}`)
    return { success: true }
  } catch (e) {
    return { success: false, error: '提交失败,请重试' }
  }
}
// _components/comment-form.tsx - Client Component
'use client'
import { useState } from 'react'
import { submitComment } from '@/actions/comment-actions'
 
export function CommentForm({ articleId }: { articleId: string }) {
  const [content, setContent] = useState('')
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState<string | null>(null)
 
  async function handleSubmit(formData: FormData) {
    setSubmitting(true)
    setError(null)
    try {
      // ✅ Server Action 返回纯 JSON 结果
      const result = await submitComment(formData)
      if (!result.success) {
        setError(result.error ?? '未知错误')
      } else {
        setContent('') // ✅ 客户端状态独立管理
      }
    } finally {
      setSubmitting(false)
    }
  }
 
  return (
    <form action={handleSubmit}>
      <input type="hidden" name="articleId" value={articleId} />
      <textarea
        name="content"
        value={content}
        onChange={(e) => setContent(e.target.value)}
        disabled={submitting}
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={submitting || !content.trim()}>
        {submitting ? '提交中...' : '提交评论'}
      </button>
    </form>
  )
}

这个修复方案的关键在于职责分离:Server Action 只负责服务端数据变更,返回一个纯 JSON 的状态结果;Client Component 只负责 UI 状态管理(loading、error、表单值)。两者之间通过纯数据通信,不共享任何函数或类实例。

React 团队在 2024 年的 React 19 稳定版发布说明里特别强调了 Server Action(现在正式称为 Server Functions)的设计原则:「Server Functions are designed for mutations, not queries. They should be treated like form submissions.」这意味着 Server Action 的定位是处理用户触发的写操作,而不是用来做数据查询的。

缓存策略不是可选优化,是边界设计的一部分

RSC 的数据获取默认是每次请求都重新执行。对于首屏的关键数据,这意味着每次用户访问页面,都会触发一次数据库查询或 API 调用。在没有缓存策略的情况下,这不是性能问题,而是正确性问题——如果上游 API 响应变慢,你的页面首屏渲染时间会线性增长。

Next.js 在 App Router 中提供了三层缓存机制,每一层和 RSC 的边界设计都有关系:

缓存层作用位置对 RSC 的影响典型用途
Request Memoization单次请求内同一请求中相同 fetch 只执行一次避免同一页面重复查询
Data Cache跨请求持久化fetch 结果跨请求复用商品列表、配置信息
Full Route Cache整个路由整个 RSC Payload 和 HTML 被缓存静态页面、博客文章
Router Cache客户端已访问路由的 RSC Payload 在客户端缓存前进后退时无需重新请求

最容易出问题的是 Data Cache 和 Server Component 的配合。如果一个 Server Component 里用了 fetch 并且设置了 cache: 'force-cache'(这是 Next.js 的默认行为),那么这个组件的数据在两次部署之间不会更新。如果你在这个组件旁边放了一个需要实时数据的 Client Component,两边的数据刷新节奏就会不一致。

坏的做法:缓存粒度不一致

// page.tsx - Server Component
import { ProductHeader } from './_components/product-header' // Server Component
import { StockIndicator } from './_components/stock-indicator' // 'use client'
 
export default async function ProductPage({ params }: { params: { id: string } }) {
  // ❌ 默认走 Data Cache,商品基础信息可能过期
  const product = await fetch(`https://api.example.com/product/${params.id}`)
    .then(r => r.json())
 
  // ❌ StockIndicator 里用另一个 fetch 查实时库存
  // 两个数据源的缓存策略完全不同,但页面上看起来是同一时刻的数据
  return (
    <div>
      <ProductHeader product={product} />
      <StockIndicator productId={params.id} />
    </div>
  )
}

修复方案:按数据时效性划分边界

// page.tsx - Server Component
import { ProductHeader } from './_components/product-header'
import { StockIndicator } from './_components/stock-indicator'
import { ProductPrice } from './_components/product-price'
 
export default async function ProductPage({ params }: { params: { id: string } }) {
  // ✅ 基础信息:长时间缓存,变更频率低
  const product = await fetch(
    `https://api.example.com/product/${params.id}`,
    { next: { revalidate: 3600 } } // 每小时重新验证
  ).then(r => r.json())
 
  // ✅ 价格信息:中等缓存,走 Data Cache 但设置合理的 revalidate
  const pricing = await fetch(
    `https://api.example.com/pricing/${params.id}`,
    { next: { revalidate: 300 } } // 5 分钟刷新
  ).then(r => r.json())
 
  // ✅ 库存信息:不缓存,每次请求实时查询
  // 放在独立的 Server Component 里,用 Suspense 包裹
  return (
    <div>
      <ProductHeader product={product} pricing={pricing} />
      {/* 库存用独立的 Suspense 边界,不阻塞首屏 */}
      <Suspense fallback={<StockSkeleton />}>
        <StockIndicator productId={params.id} />
      </Suspense>
    </div>
  )
}
// _components/stock-indicator.tsx - Server Component(不需要 'use client')
// ✅ 每次请求都实时查询,没有缓存
export async function StockIndicator({ productId }: { productId: string }) {
  const stock = await fetch(
    `https://api.example.com/stock/${productId}`,
    { cache: 'no-store' } // ✅ 显式关闭缓存
  ).then(r => r.json())
 
  return (
    <span className={stock.count > 0 ? 'text-green-600' : 'text-red-600'}>
      {stock.count > 0 ? `库存 ${stock.count} 件` : '暂时缺货'}
    </span>
  )
}

这个方案的核心思路是按数据的时效性需求来划分 Server Component 的边界,而不是按 UI 结构。同一个页面的不同部分可以使用完全不同的缓存策略,只要它们在不同的 Server Component 里,并且用独立的 Suspense 边界包裹。

SitePoint 在 2026 年发布的 RSC 流式渲染性能指南里也强调了类似的策略:「每个独立的数据依赖应该被包裹在自己的 &lt;Suspense&gt; 边界中,使用与数据维度匹配的 skeleton fallback。」这不是性能优化的建议,而是边界设计的正确做法——不同的数据源有不同的时效性需求,把它们放在同一个 Server Component 里意味着它们必须共享同一个缓存策略,这本身就是错误。

边界合理性的检查清单

在项目里逐步推行 RSC 边界设计之后,我整理了一份检查清单。每次做 Code Review 或者新增页面的时候,我会按这个清单过一遍。

设计阶段

  • 数据源识别:页面上每个数据块的数据源是什么?来自数据库、内部 API、第三方 API 还是用户输入?
  • 时效性分级:每个数据块的更新频率是多少?秒级(库存)、分钟级(价格)、小时级(商品描述)、天级(文章列表)?
  • 交互需求确认:哪些数据块需要用户交互才能更新?交互是客户端状态还是触发服务端变更?
  • 序列化兼容性检查:从 Server Component 传给 Client Component 的每个 props,是否都能通过 JSON.stringify 测试?
  • children slot 可行性:Client Component 里展示的静态内容,是否可以通过 children 从外部注入而非直接 import

实现阶段

  • 数据塑形层:Server Component 里是否有显式的 DTO 转换函数,把 ORM 对象或 API 响应转成纯 JSON 结构?
  • 类型契约:Client Component 的 props 接口(TypeScript Interface)是否和 Server Component 的 DTO 转换函数输出严格对齐?
  • Server Action 独立性:Server Action 是否定义在独立的模块文件里('use server' 文件),而非嵌在 Server Component 内部?
  • 缓存策略显式声明:每个 fetch 调用是否都显式声明了缓存策略(revalidatecache),而不是依赖默认行为?
  • Suspense 边界匹配:每个不同时效性的数据源,是否有独立的 Suspense 边界和对应的 skeleton fallback?

验证阶段

  • 生产模式验证:序列化问题在开发模式下可能只是警告,必须在生产模式下验证所有 props 传递是否正常?
  • 客户端 bundle 审计:用 next build 的 bundle analyzer 检查 'use client' 文件是否意外引入了大型服务端依赖?
  • 数据一致性检查:页面加载完成后,不同缓存层的数据是否存在明显的时效性不一致?

写在最后

RSC 的数据边界设计不是一个可以在项目开始时一次性完成的架构决策。它更像是一种持续的设计约束——每次新增数据源、每次添加交互组件、每次调整缓存策略,都需要重新审视这条边界的位置。

我在实践中总结出的核心原则只有一条:边界的位置由数据的时效性和交互性决定,而不是由 UI 结构决定。 同一个视觉区块里可能需要两到三个不同缓存策略的数据源,它们应该被拆分到不同的 Server Component 里;一个交互密集的功能区可能只需要一个 'use client' 的入口组件,其余的展示内容通过 children slot 从服务端注入。

这不是一个追求完美的过程。在真实的业务场景里,你经常需要在「边界切得更细」和「代码复杂度可控」之间做取舍。我的经验是,先从数据源的时效性入手做第一刀切分,再从交互需求入手做第二刀切分,剩下的问题通常就不会太棘手了。

参考资料

  1. React 官方文档. Server Components. https://react.dev/reference/rsc/server-components
  2. React 官方文档. 'use client' directive. https://react.dev/reference/rsc/use-client
  3. Dan Abramov et al. React Server Components RFC. 2020. https://github.com/reactjs/rfcs/pull/188
  4. Next.js 官方文档. Server and Client Components. https://nextjs.org/learn/react-foundations/server-and-client-components
  5. SitePoint. React Server Components Streaming Performance Guide 2026. https://www.sitepoint.com/react-server-components-streaming-performance-2026/
  6. 72Technologies. RSC Prop Serialization: Patterns That Actually Work. https://www.72technologies.com/blog/react-server-components-prop-serialization-wall
  7. React 官方博客. React 19 Release. https://react.dev/blog/2024/12/05/react-19
  8. Josh Comeau. Making Sense of React Server Components. https://www.joshwcomeau.com/react/server-components/

评论 0

0 / 1000