为什么 RSC 要与打包工具集成?
May 30, 2025
友情提示——这篇文章偏硬核。
React Server Components(RSC)是一种扩展了模块系统的编程范式,它将服务端/客户端应用表达为一个跨越两种运行时的单一程序。在底层,RSC 的实现主要包含两个部分:
- 用于 React 树的序列化器(见 React 仓库中的
packages/react-server
)。 - 用于 React 树的反序列化器(见 React 仓库中的
packages/react-client
)。
react-server
和 react-client
这两个包仅在 React 仓库内部使用。
当然,它们完全开源,但不会以原始形式发布到 npm。这是因为它们还缺少一个关键部分——模块系统集成。与许多(反)序列化器不同,RSC 不仅仅关注于发送数据,还涉及发送代码。比如,考虑这样一棵树:
<p>Hello, world</p>
如果你想把这个 <p>
标签转成 JSON,可以这样做:
{
type: 'p',
props: {
children: 'Hello world'
}
}
但现在再看这个 <Counter>
标签。你该如何序列化它?
import { Counter } from './client';
<Counter initialCount={10} />
'use client';
import { useState, useEffect } from 'react';
export function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
// ...
}
那么,如何序列化一个模块呢?
模块的序列化
回想一下,我们希望在网络的另一端“复活”一个真正的 <Counter>
——所以我们不仅仅是想要它的快照,而是希望它的全部交互逻辑!
一种序列化方式是直接把 Counter
的代码嵌入到我们的 JSON 里:
{
type: `
import { useState, useEffect } from 'react';
export function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
// ...
}
`,
props: {
initialCount: 10
}
}
但这样其实很糟,对吧?你并不真的想把代码作为字符串发到客户端去 eval
,也不想把同一个组件的代码反复发送。所以更合理的假设是:组件代码作为静态 JS 资源由应用服务端提供——我们可以在 JSON 里引用它。就像 <script>
标签一样:
{
type: '/src/client.js#Counter', // “加载 src/client.js 并获取 Counter”
props: {
initialCount: 10
}
}
实际上,在客户端,你甚至可以通过生成 <script>
标签来加载它。
然而,逐个从源文件按需加载 import 的方式效率极低。要知道,一个文件可能还会 import 其他文件,客户端事先并不知道完整的依赖树。你肯定不想让加载变成“瀑布式”请求。其实我们早在二十年前开发客户端应用时就知道该怎么解决:打包(bundling)。
RSC 与打包工具的绑定
正因如此,RSC 需要与打包工具集成。RSC 并不强制要求使用打包工具:这里有一个不依赖打包器的 RSC ESM 概念验证。但它主要是为了演示,因为如果没有更多优化,直接这样做效率会非常低下。
现实中的 RSC 集成都是针对具体打包器的。React 仓库中有针对 Parcel、Webpack 以及(未来的)Vite 的绑定,专门用来指定如何发送和加载模块:
- 构建阶段,它们的任务是找到带有
'use client'
的文件,并为这些入口实际创建 bundle chunk——有点像 Astro Islands 的做法。 - 服务端阶段,这些绑定教会 React 如何把模块发送给客户端。例如,打包器可能会用
'chunk123.js#Counter'
这样的方式引用模块。 - 客户端阶段,它们教会 React 如何让打包器的运行时加载这些模块。例如,Parcel 的绑定会调用一个 Parcel 专用的函数。
正是因为有了这三步,React Server 才能在遇到模块时知道如何序列化它,而 React Client 也能知道如何反序列化它。
通过打包器绑定,React Server 提供了序列化树的 API:
import { serialize } from 'react-server-dom-yourbundler'; // 针对具体打包器的包
const reactTree = <Counter initialCount={10} />;
const outputString = serialize(reactTree); // 得到类似上面 JSON 的字符串
然后你可以把 outputString
存到磁盘、通过网络发送、缓存等等——最终交给 React Client。React Client 会反序列化整个树,并按需从引用的模块加载代码:
import { deserialize } from 'react-server-dom-yourbundler/client'; // 针对具体打包器的包
const outputString = // ... 网络接收、磁盘读取等...
const reactTree = deserialize(outputString); // <Counter initialCount={10} />
这样,只要一切顺利,你就能得到一棵普通的 JSX 树,就像你自己在客户端写 <Counter initialCount={10} />
一样。你可以像操作普通 JSX 树那样随意处理它——渲染、存入 state、转成 HTML 等等。
const outputString = // ... 网络接收、磁盘读取等...
const reactTree = deserialize(outputString); // <Counter initialCount={10} />
// 你可以像操作普通 JSX 树一样,比如:
const root = createRoot(domNode);
root.render(reactTree);
这就是像 Next.js 这样的 RSC 框架在底层用到的 API。
如果你想用这些底层 API 玩玩 RSC,看看你的 React 树是如何被(反)序列化的,Parcel 的 RSC 实现是个不错的起点。
(上文中的 serialize
和 deserialize
只是示意,具体名称由绑定包决定(而且可能有多个重载)。比如 @parcel/rsc
包(它是对底层 react-server-dom-parcel
绑定的一个薄封装)提供的序列化叫 renderRSC
,反序列化叫 fetchRSC
。此外,它们的实际实现都是非阻塞的,并且支持双端流式处理。)