Skip to main content

React Server Components 解析

RSC 不是「在服务端跑的组件」这么简单。它把组件分成服务端与客户端两类,服务端组件的代码根本不会进 bundle。

一、把重依赖留在服务端​

服务端组件产出的不是 HTML,而是一份可流式的序列化 payload——里面描述了组件树的结构、已渲染出的内容,以及客户端组件该去哪里加载。客户端拿到这份数据后重建组件树,只在需要交互的地方补上逻辑。

理解这一点就能理解它的全部收益:组件本身的代码不进 bundle,组件里 import 的重依赖也不进 bundle。一个商品详情页要渲染 Markdown 说明、要格式化日期、要高亮代码片段,传统做法里这三个库都要打进客户端产物;改成服务端组件之后,它们全部留在服务端,页面 JS 里只留下真正需要交互的那部分。

// 服务端组件:marked、date-fns 之类只在这里用,不会进客户端产物
import { marked } from 'marked'

export default async function ProductPage({ id }) {
const product = await db.product.find(id)
return (
<article>
<h1>{product.title}</h1>
<div dangerouslySetInnerHTML={{ __html: marked(product.description) }} />
<BuyButton productId={id} /> {/* 客户端组件,只有它进 bundle */}
</article>
)
}

它顺带解决的还有 waterfall:取数发生在服务端、离数据源更近,不必等客户端下载完 JS 再发起请求。

服务端(不进客户端 bundle)async 取数markdown 渲染库date-fns 格式化代码高亮库这些依赖的代码不下发客户端 bundle(只留交互)use client 的交互组件事件、状态、effect体积显著下降序列化 payload
图:RSC 的核心收益——服务端组件及其 import 的重依赖都不进客户端 bundle

二、服务端与客户端的分界​

服务端组件能做的事:用 async/await、直连数据库或内部服务、读取服务端密钥、导入体积大的依赖。不能做的事:用 useState、useEffect、事件处理、浏览器 API——因为它的代码不会出现在浏览器里,这些能力无从谈起。

客户端组件就是传统意义上的 React 组件:有状态、有副作用、能绑事件。文件顶部加 use client 标记。

两条最容易踩错的约束:

第一,use client 标记的是「边界入口」,不是「这个组件只在客户端跑」。 从这个文件往下 import 的整个子树都会被打进客户端包。所以标记的位置要尽量往叶子上推——在一个靠近根的文件顶部写 use client,等于把整棵树都推给了浏览器,RSC 的收益立刻归零。

第二,客户端组件不能 import 服务端组件。 但反过来可以,而且这是打通两类组件的关键模式:把服务端组件作为 children 传给客户端组件。

// 客户端组件:只负责交互外框,内容由外部传入
'use client'
export function Collapse({ children }) {
const [open, setOpen] = useState(false)
return (
<section>
<button onClick={() => setOpen(!open)}>{open ? '收起' : '展开'}</button>
{open && children}
</section>
)
}

// 服务端组件:children 在服务端渲染完,再作为结果传进去
<Collapse>
<HeavyMarkdownContent source={product.description} />
</Collapse>

父级在服务端已经把 children 渲染成了结果,客户端组件拿到的是「渲染完的东西」,不需要知道它是怎么来的。所以当你要「在客户端组件里显示一大块静态内容」时,正确做法是把静态内容提到上层,用 children 传下去,而不是在客户端组件里 import 它。

'use client' 在这里整棵子树被推进客户端子组件 A子组件 B子组件 C标记位置越靠近叶子越好:在靠近根的文件顶部写 `use client`,等于把整棵树交给浏览器,RSC 的体积收益直接归零。
图:`use client` 标的是「边界入口」,标记位置决定整棵子树的归属

三、和 SSR 不是一回事​

这是两组正交的概念,混在一起是绝大多数误解的源头:

  • SSR / CSR 说的是「渲染发生在什么时候」——服务端出 HTML,还是浏览器出 DOM;
  • RSC / 普通组件 说的是「这是什么类型的组件」——代码下不下发。

所以存在四种组合,RSC 可以配 SSR(服务端渲染出首屏 HTML,组件代码不下发),也可以配纯客户端渲染(SPA 在运行时消费 RSC payload)。RSC 并不依赖 SSR。详细的四象限与选型在《RSC 与 SSR 的关系与选型》里展开,这里只记住一句话:SSR 决定 HTML 在哪出,RSC 决定代码下不下发。

四、收益从哪来、什么项目不值​

判断依据不是「项目新不新」,而是两条:交互密度和依赖体积。

适合的:

  • 内容为主、交互集中在少数几处(详情页、文档、营销页、列表页);
  • 依赖很重但交互很轻(Markdown 渲染、图表、语法高亮、国际化语言包);
  • 首屏速度与 SEO 是硬指标。

不适合的:

  • 强交互应用(拖拽编辑、实时协同、画布类)——几乎每个组件都要客户端能力,边界划不出去;
  • 离线优先或需要频繁读浏览器状态的应用——组件没法在服务端渲染;
  • 已有大量客户端状态管理且短期无法切分的老项目——迁移成本高于收益。

一句话:服务端组件是默认,客户端组件是例外。新项目应该从「哪些部分必须跑在浏览器」这个问题出发,而不是反过来。

想确认某个依赖到底有没有进客户端产物,别猜,构建后直接搜:

# 服务端专用的依赖不应出现在客户端 chunk 里
grep -rl "marked" .next/static/chunks/ || echo "未进客户端产物"

# 顺带看客户端产物总大小,验证「留在服务端」是否真的省下来了
du -sh .next/static/chunks/

五、边界处的数据传输​

跨边界的数据必须可序列化。 普通函数、类实例、Symbol、包含它们的嵌套对象都传不过去。有一个官方例外:带 'use server' 的 Server Function 可以作为 prop 传给客户端组件——但它传过去的是引用,不是能本地调用的实现。这不是实现上的限制,而是这套机制的本质——数据要被序列化进 payload 再在另一端还原。

最常见的踩坑是想把一个回调传给客户端组件。正确做法是传标识符,让客户端组件自己根据标识符决定行为,或者把需要的行为放在客户端组件内部。

// 错:函数不能跨边界
<BuyButton onDone={() => markAsSold(id)} />

// 对:传标识,行为在客户端组件内部
<BuyButton productId={id} action="markAsSold" />

状态要按来源切分。 服务端取回来的数据留在服务端组件里往下传;只有「用户交互产生的状态」(展开/收起、输入中的值、选中的项)才需要放进客户端状态。很多人迁移时的第一步错误,是把原本在 store 里的服务端数据原封不动搬到客户端,于是既享受不到 RSC 的收益,又要额外维护一遍缓存。

第三方库要检查兼容性。 依赖浏览器 API 或在渲染期读 window 的库,必须放在客户端组件里。主流库大多已经标注了 RSC 兼容性,动手迁移前查一遍能省很多时间。

一条很容易踩的规则:一旦某个文件标了 use client,它下面的整棵子树都进客户端:

// page.jsx(服务端组件)
import HeavyChart from './heavy-chart' // 这个文件标了 'use client'

export default function Page() {
return (
<HeavyChart>
{/* 即便这个子组件本身是服务端组件,作为 children 传进去也要看渲染位置 */}
<Table rows={rows} />
</HeavyChart>
)
}

六、关于 use client 的几个误判​

RSC 不是「在服务端跑的组件」的同义词。它是一次渲染发生在服务端、且这份代码不下发给浏览器的组件类型;SSR 同样在服务端跑,但那是另一件事。

服务端组件里也用不了 hooks——state、effect、事件处理、浏览器 API 都不行。能用的是 async/await、直连数据源、读密钥。

跨边界传值也有讲究:函数不能像普通 prop 一样传过去,必须可序列化,通常传标识符而不是传函数。

把 use client 加在根组件上看似省事,实际是把整棵子树都推给了浏览器,RSC 的体积收益直接归零。

RSC 不是「服务端渲染的新名字」,它只是把「哪些代码不必发给浏览器」这件事,交给了组件类型来表达。

RSC 的边界规则以官方 react.dev:Server Components 为准,哪些组件能跑在服务端、怎么传 props 都列在里头。