学习 RSC(一):项目实战与底层原理解析

230 阅读5分钟

每隔一段时间,React Server Components(RSC)就会掀起一轮新的讨论。虽然它早在 2020 年底就被提出并实现,但至今仍未广泛普及,原因之一就是它的学习曲线异常陡峭,理解门槛很高。RSC 似乎像一个魔咒:越解释,反而越让人困惑。误解层出不穷,澄清却始终不够。最近 Dan Abramov 又推出了系列长文试图再次解构 RSC 的底层理念,我读下来依然感到一头雾水。其实早在 2023 年我就在公司做过一次关于 RSC 的技术分享,但直到现在,面对它依然有种“理解了又没完全理解”的感觉。

React Server Components(RSC)是 React 官方提出的新一代架构方案,它通过引入服务端组件(Server Components)与客户端组件(Client Components)的边界,实现了更轻量的数据传输、更清晰的职责划分和更高效的渲染模型。在这篇文章中,我们将基于一个极简的从零搭建的 RSC 框架项目,从代码实现出发,深入剖析 React Server Components 的运行机制与底层原理。

一、RSC 的核心理念与运行模型

传统 React 应用中,所有组件最终都需要打包发送到浏览器执行,即使这些组件只在服务端使用(例如发起数据库查询的组件),也不得不冗余传输。而 RSC 的目标就是:将只用于服务端的组件永远保留在服务端,避免发送到客户端,最终达到减少 bundle 体积、提升首屏性能的效果。

其运行流程可以总结如下:

  1. 客户端请求 /rsc 获取初始组件树的 RSC Payload(非 HTML)
  2. 服务端调用 renderToPipeableStream 将组件序列化为 RSC Payload(JSON 格式的组件流)
  3. 客户端用 createFromReadableStream 反序列化这个 Payload 得到组件树,并挂载到 DOM 中

这种模式极大提升了首屏渲染的效率,同时也天然支持 Suspense 等流式渲染特性。

二、项目结构概览

我们基于 react-server-dom-webpack 实现了一个最小可运行的 RSC 框架,目录结构如下:

├── app.js            // RSC 服务端组件入口
├── client.js         // 客户端入口文件
├── server.js         // 服务端应用,基于 Express 提供接口
├── build.js          // Webpack 构建脚本
├── db.js             // 数据库模块,提供 getMovies 和 updateVote 接口
├── db.sqlite         // SQLite 数据库文件
├── package.json

下面我们分模块分析其关键实现原理。

三、服务端渲染:renderToPipeableStream 的工作机制

server.js 中,核心逻辑是:

app.get('/rsc', (req, res) => {
  res.setHeader('Content-Type', 'text/x-component');
  const tree = React.createElement(App);
  const rscStream = ReactServerDOMWebpackServer.renderToPipeableStream(tree);
  rscStream.pipe(res);
});

此处 App 是一个异步组件,包含数据请求逻辑:

export async function App() {
  const moviesPromise = getMovies();
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <MovieList moviesPromise={moviesPromise} />
    </Suspense>
  );
}

在服务端执行这段组件树时,renderToPipeableStream 会捕捉到 moviesPromise,将其包装成异步片段,在数据 resolve 前发送 fallback,在 resolve 后流式推送组件数据。

这就借助了 React 的 Suspense 和流式渲染模型:服务端按需计算组件,按需发送组件片段,实现边算边传边渲染。

最终,React Server DOM 将组件树序列化为一个特殊格式的 RSC Payload,内容如:

1
{"type":"module","id":"MovieList","name":"default"}

这个 Payload 会通过 HTTP 流被推送给浏览器。

四、客户端反序列化:createFromReadableStream 解构

客户端代码如下:

const initialReactTreePromise = fetch('/rsc')
  .then((res) => ReactServerDOMWebpackClient.createFromReadableStream(res.body));

function App() {
  return React.use(initialReactTreePromise);
}

ReactDOMClient.createRoot(document.getElementById('root')).render(<App />);

此处调用的是 react-server-dom-webpack/client 提供的核心方法:

  • createFromReadableStream(stream) 会解析服务端流中的 payload,逐步重建组件树
  • 内部通过异步挂起机制(React.lazy + Suspense)等待模块数据 resolve

RSC Payload 中引用的组件、模块,可能并未在浏览器预加载,这时候就需要基于模块 ID 动态解析。

RSC 的模块引用是由 Webpack Plugin 注入的 client reference,在构建时会生成特殊的 module map,客户端用它来解析服务端传过来的模块 ID。

五、模块系统:client/server 分界的模拟实现

虽然我们在这个 demo 中没有使用 .client.js / .server.js 文件后缀,但通过 Express + Webpack + Babel,我们仍然完成了以下关键特性:

  • 服务端执行 React 组件,注入数据层逻辑(db.js)
  • Webpack 构建出的客户端 bundle 仅包含 client 相关代码

Webpack 构建配置见 build.js

entry: './client.js',
output: { filename: 'client.js' },
module: {
  rules: [{
    test: /.js$/, use: 'babel-loader',
    options: { presets: ['@babel/preset-react'] }
  }],
},

客户端代码通过 Webpack 构建后放在 dist/client.js,由服务器静态托管。

服务端通过 @babel/register 直接在运行时转译 JSX,保持编写体验一致。

六、数据层的异步加载与挂起机制

数据库访问封装在 db.js 中:

function getMovies() {
  return new Promise((resolve) => {
    db.all('SELECT * FROM movies', [], (err, rows) => {
      setTimeout(() => resolve(rows), Math.random() * 5000);
    });
  });
}

在服务端组件中直接调用:

const moviesPromise = getMovies();
<Suspense fallback={<div>Loading...</div>}>
  <MovieList moviesPromise={moviesPromise} />
</Suspense>

React 捕捉到 moviesPromise 后,在组件未 resolve 时挂起,并使用 fallback;resolve 后继续渲染并将结果传给客户端。

这也是 React RSC 支持“全栈 Suspense”的关键机制:服务端组件可异步挂起并恢复渲染,同时客户端通过 Suspense 实现流畅过渡。

七、总结:RSC 的最小实现中学到的核心要点

这个项目是一个 RSC 的极简实践,但已经涵盖了以下关键技术点:

  1. 如何构建一个 RSC payload 并通过流发送到浏览器
  2. 如何在浏览器中异步反序列化 RSC payload 并动态加载模块
  3. 如何通过 Suspense 实现服务端异步数据的加载与客户端渲染衔接
  4. 如何构建模块引用关系并模拟实现 client/server 分界

React Server Components 是前端架构的一次重要演进,它允许我们将组件分布在客户端与服务端之间,用最小的成本提升性能与开发体验。