13|React Server Components(RSC)在仓库中的落点与边界

13|React Server Components(RSC)在仓库中的落点与边界

本栏目是「React 源码剖析」系列:我会以源码为证据、以架构为线索,讲清 React 从运行时到核心算法的关键设计。开源仓库:https://github.com/facebook/react

在第 12 篇我们讲了 Fizz(流式 SSR):

  • SSR 的"流式"不是把 HTML 字符串 write 进 stream,而是一套可回压(backpressure)的队列系统
  • Task 负责推进渲染(work),Segment/Boundary 负责组织写出(flush)

文章末尾我留了一个预告:第 13 篇进入 React Server Components(RSC)。

这篇会回答一个经常让读源码的人卡壳的问题:

  • packages/react-server/ 在仓库里到底是什么?它是不是"服务端渲染器"?
  • RSC 到底是"协议"还是"渲染器"?为什么还要一个 react-server-dom-webpack
  • 它与 Fizz(SSR)/Hydration 的契约边界到底在哪:哪些共用 Server 基建,哪些是完全不同的链路?

本文核心文件:

  • packages/react-server/src/ReactFlightServer.js
  • packages/react-client/src/ReactFlightClient.js
  • packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js
  • packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js
  • packages/react-server/src/ReactFlightReplyServer.js
  • packages/react-client/src/ReactFlightReplyClient.js
  • packages/react-server/src/ReactFlightActionServer.js
  • packages/react-server-dom-webpack/package.json

0) 先立一个"非直觉结论":RSC 的核心不是 HTML,而是一个可流式传输的"组件模型协议"(Flight)

当你把 RSC 当成"SSR 的升级版",很多概念都会纠缠在一起:

  • SSR:服务端把组件树渲染成 HTML(Fizz),客户端拿 HTML 做 hydration
  • RSC:服务端把组件树渲染成一份"可被客户端还原"的模型(Flight payload),客户端消费这份模型

在仓库里这种边界非常清晰:

  • Fizz(HTML SSR)在 react-server/src/ReactFizzServer.js
  • RSC/Flight 在 react-server/src/ReactFlightServer.jsreact-client/src/ReactFlightClient.js

所以你可以用一句更准确的话来记:

RSC 更像"React 的组件协议 + 运行时",而不是"ReactDOMServer 的另一个 API"。


1) 先看仓库分层:为什么会同时存在 react-serverreact-clientreact-server-dom-webpack

1.1 react-server:Flight 的"服务端端点",负责把模型编码成字节流

你打开 packages/react-server/src/ReactFlightServer.js,会看到它做的事情和 Fizz 很像,但产物完全不同:

  • 有自己的 Request
  • 有自己的 Task
  • 有自己的 startWork / startFlowing / stopFlowing
  • flush 的判断依然通过 writeChunkAndReturn 来尊重 backpressure

区别在于:Fizz flush 的主要内容是 DOM/HTML 指令集;Flight flush 的主要内容是"行协议(rows)"。

1.2 react-client:Flight 的"客户端端点",负责把字节流解码为可用的 React 模型

Flight 的 consumer 并不一定是浏览器:

  • 可以是浏览器里的 RSC runtime
  • 也可以是服务端自己(比如解码 action 的 bound args、或者 server 作为 client 消费 reply)

这也是为什么 Flight 的 client 运行时被放到 packages/react-client/src/ReactFlightClient.js

ReactFlightClient.js 里你会看到它维护一套"Chunk 状态机"(pending/blocked/resolved_model/resolved_module/fulfilled/rejected),通过解析流中的 rows,把引用、模块、错误逐步拼装起来。

1.3 react-server-dom-webpack:Flight 协议的 DOM + Bundler 绑定层(不是协议本身)

react-server-dom-webpack/package.json 的描述非常直白:

  • React Server Components bindings for DOM using Webpack. This is intended to be integrated into meta-frameworks. It is not intended to be imported directly.

它解决的是"协议之外"的现实问题:

  • 如何把 Client Component/Server Action 变成可被序列化的引用?(靠 bundler 产物映射)
  • 如何在 Node/WebStreams/Edge 环境把 Flight Request 接到具体 stream API?renderToPipeableStream / renderToReadableStream

换句话说:

  • react-server/react-client 是协议运行时
  • react-server-dom-webpack 是一个"把运行时挂到 Webpack 生态的适配器"

2) 入口 API:renderToPipeableStream(Flight)长得像 Fizz,但它写出去的是"协议行"

文件:packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js

你会看到它也提供了一个 Node 风格入口:

  • renderToPipeableStream(model, webpackMap, options)

其结构几乎复刻了 Fizz 的 pipeable stream:

  • createRequest(...)
  • startWork(request)
  • pipe(destination)startFlowing(request, destination)
  • 监听 drain/error/close,在 drain 时再 startFlowing

关键片段(省略类型,强调结构):

js 复制代码
const request = createRequest(model, webpackMap, ...);
let hasStartedFlowing = false;
startWork(request);
return {
  pipe(destination) {
    if (hasStartedFlowing) throw new Error(...);
    hasStartedFlowing = true;
    startFlowing(request, destination);
    destination.on('drain', () => startFlowing(request, destination));
    destination.on('error', () => { stopFlowing(request); abort(request, new Error(...)); });
    ...
    return destination;
  },
  abort(reason) { abort(request, reason); },
};

这段相似性不是巧合:Fizz 与 Flight 都在用 React 自己抽象出来的"可回压写出模型"(host config 里的 writeChunkAndReturn)。

但注意一个本质差异:

  • Fizz 的 children 是 ReactNode → 目标是 HTML
  • Flight 的 modelReactClientValue → 目标是"可被客户端还原的模型"

所以,虽然都叫 renderToPipeableStream,它们是两条完全不同的渲染管线。


3) Flight 的最小协议单位:Row(行)

3.1 Row header:id.toString(16) + ':' + tag

ReactFlightServer.js 里有一个非常"协议味"的小函数:

js 复制代码
function serializeRowHeader(tag: string, id: number) {
  return id.toString(16) + ':' + tag;
}

也就是说,每一行(row)都至少包含:

  • 一个 hex 编码的 idtoString(16)
  • 一个 tag(表示这行是什么类型)
  • 以及后续的 JSON/文本内容

例如:

  • import 行:serializeRowHeader('I', id) + json + '\n'
  • model 行:serializeRowHeader('D', id) + json + '\n'
  • error 行:serializeRowHeader('E', id) + stringify(errorInfo) + '\n'

你可以把 Flight stream 想成"按行发送的一组增量补丁":

  • 先告诉你"某个 id 对应一个模块引用"(I)
  • 再告诉你"某个 id 对应一段模型 JSON"(D)
  • 过程中如果出错,就发错误行(E)

3.2 Flight 的 flush:flushCompletedChunks 就是把不同队列按优先级写出去

还是在 ReactFlightServer.jsflushCompletedChunks(request) 会按队列顺序写出:

  • imports(模块)
  • hints(资源提示)
  • debug(DEV)
  • regular model chunks
  • error chunks

写出的关键逻辑仍然是:

js 复制代码
const keepWriting: boolean = writeChunkAndReturn(destination, chunk);
if (!keepWriting) {
  request.destination = null;
  ...
  return;
}

这和 Fizz 的"通过 request.destination = null 停止 flowing"是一致的:

  • 下游没容量(backpressure) → 立刻停止,等待 drain 再继续

因此,虽然 RSC 的内容不是 HTML,但"流式与回压"的工程模型和 Fizz 是同一类。


4) RSC 为什么必须引入"引用类型":ClientReference / ServerReference

RSC 的一个核心约束是:

  • Server Components 可以 import Client Components
  • 但不能"执行它们",也不能"点属性拿内部导出"

因为 Client Components 的代码最终要在浏览器跑;在服务端,你只能拿到一个"可序列化的引用"。

4.1 引用的形状:$$typeof + $$id

文件:packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js

它定义了两个 tag:

  • Symbol.for('react.client.reference')
  • Symbol.for('react.server.reference')

并通过 registerClientReference / registerServerReference 把这些元数据挂到函数/代理对象上:

  • client reference:$$typeof$$id$$async
  • server reference(Server Action):$$typeof$$id$$bound(以及 DEV 下的 $$location

4.2 为什么 Server 端不允许"dot into a client module"?

同一个文件里有一个非常强硬的 Proxy handler:

  • 访问 then → 直接抛错:你不能在 server component 里 await 一个 client module
  • 访问任意属性 → 直接抛错:你不能对 client module 做点访问

错误文案把约束说得很清楚:

  • You cannot dot into a client module from a server component. You can only pass the imported name through.

这就是 RSC 的"编程模型边界"落到运行时代码的样子:

  • Server 端拿到的不是模块本体,而是一张"可被序列化的能力卡片"。

5) Server Actions:从"函数调用"变成"可序列化的服务端能力"

很多人以为 Server Actions 是框架层(Next.js 等)发明的,其实 React 仓库里已经有一套非常明确的协议实现。

5.1 Client 侧:Server Action 以 ServerReferenceId 形式出现在流里

react-client/src/ReactFlightReplyClient.js 里:

  • serializeServerReferenceID(id)'$h' + id.toString(16)

也就是说,Server Action 在协议里会以类似 $h1a 这种形式出现(h 只是一个标记前缀)。

5.2 Server 侧:decodeAction 从 FormData 中提取 $ACTION_* 字段

文件:packages/react-server/src/ReactFlightActionServer.js

它做的事可以压缩成一句话:

  • FormData 里找 $ACTION_REF_*$ACTION_ID_*
  • 把"引用元数据"解码出来(包含 idbound args
  • serverManifestid resolve 成真正的函数实现
  • 最后把剩余的 formData 绑定到 action 的第一个参数上返回

关键逻辑(省略细节):

js 复制代码
body.forEach((value, key) => {
  if (!key.startsWith('$ACTION_')) {
    formData.append(key, value);
    return;
  }
  if (key.startsWith('$ACTION_REF_')) {
    const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
    const metaData = decodeBoundActionMetaData(body, serverManifest, formFieldPrefix);
    action = loadServerReference(serverManifest, metaData.id, metaData.bound);
    return;
  }
  if (key.startsWith('$ACTION_ID_')) {
    const id = key.slice(11);
    action = loadServerReference(serverManifest, id, null);
    return;
  }
});

这段代码的意义是:

  • Server Action 不是"把函数名拼进 URL"
  • 它更像"把一个可验证的引用 id + 绑定参数"编码进一次提交中

serverManifest 则是 bundler/框架提供的"引用映射表"。

5.3 你会在这看到一个关键设计:Server 也会"作为 Client"去解析自己

ReactFlightActionServer.js 的 import:

  • 它从 react-client/src/ReactFlightClientConfig 里 import resolveServerReference/preloadModule/requireModule

这也是为什么仓库要把 Flight client runtime(以及 config)拆出来:

  • 有些场景下,server 端需要"消费"一段 Flight 编码的数据(比如 bound args)

6) RSC 与 Fizz/Hydration 的契约边界:它们是两套引擎,但会在框架层组合

现在我们可以把三者的职责拆清楚:

  • Fizz(SSR):输出 HTML + hydration 指令,目标是"可首屏展示、可续接"
  • Hydration:客户端把现有 DOM 与 Fiber 对齐,选择性补水/事件回放属于这条链路
  • Flight(RSC):输出"组件模型协议",目标是"把 Server Components 的结果以协议形式交给 client"

在 React 仓库里:

  • Fizz 的核心是 ReactFizzServer.jsTask/Segment/Boundary、flush 队列、preamble/postamble、writeStartPendingSuspenseBoundary
  • Flight 的核心是 ReactFlightServer.jsRequest/Task、按 row 写出、import/model/error 等 tag

它们的共同点是工程模型:

  • 都把"work(推进计算)"和"flush(写出)"解耦
  • 都通过 writeChunkAndReturn/destination 开关实现 backpressure

不同点是协议与目标:

  • Fizz 的消费方是浏览器 DOM
  • Flight 的消费方是 Flight client runtime(它最终可能再驱动 React 渲染)

你在真实框架中看到的"RSC + SSR"通常是:

  • 首屏用 Fizz 输出 HTML shell
  • 同时把 Flight payload 作为一条并行数据流注入/传输
  • 客户端用 Flight runtime 消费 payload,然后把 Client Components 部分交给浏览器执行

React 仓库把它们拆开,是为了保持底层引擎的可复用性:

  • 协议(Flight)不绑定 DOM
  • DOM 的 bundler 绑定(webpack)也不污染协议本体

总结:react-server 不是"SSR 版 react-dom",而是 Flight(RSC)的协议运行时

把本文压缩成三句话:

  • react-server/react-client 实现的是 Flight:一种可流式传输的组件模型协议,它有自己的 Request/Task/flush 体系。
  • react-server-dom-webpack 是协议绑定层:解决 bundler manifest、client/server reference 形态、以及 Node/WebStreams 等宿主入口。
  • RSC 与 Fizz 是两套引擎:它们在框架层通常会组合,但在仓库里边界清清楚楚。

下一篇预告

第 14 篇我们会更进一步:把 Hook 的实现视角接回 Fiber Update Queue,解释"Hook API 的薄、Dispatcher 的厚,以及 lane/调度如何穿过 Hook 更新"。

相关推荐
OEC小胖胖1 小时前
14|Hook 的实现视角:从 API 到 Fiber Update Queue 的连接点
前端·react.js·前端框架·react·开源库
i7i8i9com1 小时前
React 19学习基础-2 新特性
javascript·学习·react.js
军军君011 小时前
Three.js基础功能学习十:渲染器与辅助对象
开发语言·前端·javascript·学习·3d·前端框架·ecmascript
Marshmallowc1 小时前
React useState 数组 push/splice 后页面不刷新?深度解析状态被『蹭』出来的影子更新陷阱
前端·react.js·前端框架
GIS之路1 小时前
ArcGIS Pro 添加底图的方式
前端·数据库·python·arcgis·信息可视化
Mo_jon1 小时前
vite + vue 快速构建 html 页面 (舒适编写html文件)
前端·vue.js·html
步步为营DotNet2 小时前
深度解析.NET 中Nullable<T>:灵活处理可能为空值的类型
java·前端·.net
rqtz2 小时前
前端相关动画库(GSAP/Lottie/Swiper/AOS)
前端·swiper·lottie·gsap·aos·font-awsome
C_心欲无痕5 小时前
前端如何实现 [记住密码] 功能
前端