跳到主内容

React Server Components 理解与实践:从 CSR 到 RSC 的心智模型迁移指南 ​

把一个写了多年的 CSR(客户端渲染)React 项目迁到 RSC(React Server Components)时,很多老手的第一个动作是:在所有文件顶部加上 'use client',让代码继续按老习惯跑。项目确实能跑起来,但这等于把 RSC 最重要的收益全部放弃了——服务端组件不进客户端 bundle、组件内直接 await 取数、0 KB JS 的渲染树。React 19 稳定化之后,RSC 已成为 Next.js App Router 等主流方案的设计基石,这不是「可学可不学」的新特性,而是组件架构的范式切换。

范式切换的关键从来不是 API,而是心智模型。这篇文章把 CSR 时代的三个本能反应逐个「重写」,帮你完成从「组件在浏览器里活一遍」到「组件树天然分布在两端」的思维转换。

心智模型切换一:'use client' 不是「标记客户端组件」,而是「声明服务器边界」 ​

最普遍的误解是把 'use client' 当成「把这个文件标记为客户端组件」。真实语义恰恰相反:它是一个边界声明——「从这个文件起,往下(它引入的所有模块)都是客户端世界」。你在哪里写下这行指令,决定了客户端 bundle 从哪里开始生长。默认不加该指令的组件全是服务端组件,运行在 Node 里,渲染结果以 RSC Payload 的形式流式传给客户端。

由此推出的实践准则是:边界要尽量下压。交互元素(按钮、输入框、下拉)才是真正的客户端组件,包裹它们的列表、卡片、页面骨架都应该留在服务端。对比一下两种写法的 bundle 差异:

tsx
// ❌ 反模式:把整个区块标为客户端,数据列表、静态文案全部进了 bundle
'use client'
export function OrderPanel({ orders }: { orders: Order[] }) {
  const [tab, setTab] = useState('all')
  return (
    <section>
      <h1>订单管理</h1>  {/* 静态内容也被拖进客户端 JS */}
      <Tabs value={tab} onChange={setTab} />  {/* 唯一真正的交互点 */}
      <OrderTable orders={orders} />
    </section>
  )
}

// ✅ 正确姿势:服务端组件做外壳,客户端组件只包交互点
// OrderPanel.tsx(服务端组件,可直接 async 取数)
export default async function OrderPanel() {
  const orders = await db.orders.list()   // 服务端直接查库,无需 API 中转
  return (
    <section>
      <h1>订单管理</h1>
      <OrderTabs orders={orders} />       {/* 交互下沉到子组件 */}
    </section>
  )
}

// OrderTabs.tsx:只有带状态的这一层需要 'use client'
'use client'
export function OrderTabs({ orders }: { orders: Order[] }) {
  const [tab, setTab] = useState('all')
  const filtered = useMemo(() =>
    tab === 'all' ? orders : orders.filter(o => o.status === tab), [orders, tab])
  return (
    <>
      <Tabs value={tab} onChange={setTab} />
      {/* OrderTable 作为 children 形式从服务端传入,不进本组件的边界 */}
      <OrderTable orders={filtered} />
    </>
  )
}

注意正确姿势里的一个精妙组合:服务端组件可以作为 children 或 props 穿过客户端边界。OrderTable 保持服务端形态,由服务端渲染好再「塞」给客户端的 OrderTabs,交互态与静态渲染互不拖累。这是 RSC 组合模式的核心,值得反复体会。

心智模型切换二:props 跨边界是一次「序列化」,不是「引用传递」 ​

服务端组件渲染成 Payload 再传给客户端,这个过程类似 JSON 序列化。因此跨边界的 props 必须可序列化:字符串、数字、普通对象、数组、Date 与 Promise(React 19 起支持)可以通行;函数(除 Server Actions)、class 实例、复杂循环引用结构不行。CSR 时代「传个回调下去」的本能动作,在边界上会直接报错,正确替代方案分场景:

tsx
// 服务端组件:取数逻辑留在服务端,避免「传函数」需求
export default async function ProductPage() {
  const products = await db.products.list()
  return (
    <>
      {/* 场景一:子组件要展示服务端数据 → 传可序列化数据即可 */}
      <ProductFilter products={products} />

      {/* 场景二:子组件要触发服务端写操作 → 传 Server Action('use server' 标记的函数) */}
      <DeleteButton action={deleteProduct} />
    </>
  )
}

另外两个迁移期高频报错也源于这个模型:在服务端组件里调用 useState / useEffect / 浏览器 API(window、localStorage)会直接失败,因为它们根本不在浏览器里运行;反过来,在客户端组件里 import 服务端专用库(含 node 内置模块的包)会导致构建报错。开发时心里要始终装着「当前文件运行在哪一端」这根弦,IDE 插件或 lint 规则(如 eslint-plugin-react-compiler 配合边界检查)能帮新手把这个意识固化下来。

心智模型切换三:缓存从默认开启走向默认关闭 ​

从 CSR SPA 迁来的人对「请求」的直觉是:每次交互手动发请求、自己管理 loading 与缓存。而早期 App Router 的 fetch 默认被缓存,Next.js 15 起 fetch 请求与 GET 路由处理默认不再缓存,显式声明 cache: 'force-cache' 或配置 revalidate 才缓存。迁移老项目时要专门审一遍数据层:原来依赖「默认缓存」的页面,升级后可能出现请求放大;反之原来手动 revalidate 的地方,要理解新的 revalidateTag / 按需失效语义。缓存策略没有默认正确答案,但「默认值变了」这件事必须写进迁移清单,否则线上行为会静默漂移。

范式之外:数据所有权与团队协作界面也在变 ​

还有一个迁移期的观察值得分享:RSC 改变的不只是渲染位置,还有团队的数据所有权划分。过去前端团队通过 BFF 层聚合接口,聚合逻辑写在 Node 中间层;RSC 之后取数直接发生在组件内,BFF 的聚合层被组件树天然取代,前后端的协作界面从「接口契约」变成了「组件边界」。我们迁移半年后的体感是:简单页面的胶水代码少了近一半,但复杂页面的数据预取与流式降级设计反而更需要整体规划——范式带来的从来不是「更少思考」,而是「把思考花在更对的地方」。

避坑总结 ​

  1. 边界上移是最贵的错误。在页面级文件随手写 'use client',整棵子树都会被打进客户端 bundle,RSC 收益归零。发现某个组件需要状态时,正确动作是「把状态包一层薄壳下压」,而不是把外壳改成客户端组件。
  2. 跨边界传函数/类实例必报错。报错信息通常出现在构建或渲染期,定位困难;建立约定:跨边界只传数据与 Server Actions,复杂对象在服务端拆成扁平结构,Date 与 Map 这类实例转成字符串或普通对象再传。
  3. 不要在客户端组件里 import 服务端工具。含数据库客户端、密钥读取、node:crypto 的模块一旦被客户端边界引用,要么构建失败,要么更危险——密钥被打进 bundle 随 JS 下发。用目录约定(如 server-only 包标记)从物理上隔离。
  4. 数据获取优先 await 组件而不是 useEffect。把取数逻辑塞进 useEffect 是 CSR 肌肉记忆,RSC 下应该在服务端组件顶层直接 await,配合 <Suspense> 做流式降级,首屏不再有「白屏 + 转圈」的双段等待。
  5. 升级框架大版本先审缓存语义。Next.js 15 将 fetch 与 GET 路由的默认缓存关闭,迁移清单里必须有数据层一节:逐页面确认缓存策略是显式选择的,而不是继承自旧默认值。

写在最后 ​

RSC 的本质是把「组件」这个抽象从浏览器单机搬到了网络两端:渲染发生在离数据最近的地方,交互发生在离用户最近的地方,中间只流动可序列化的渲染结果。心智模型切换完成后,你会发现很多 CSR 时代的样板代码——API 层、客户端状态管理中的服务端数据镜像、瀑布式的 loading 链——都有了更简单的替代方案。范式变了,好架构的标准没变:让数据少跑路,让边界更清晰。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。