读者定位:熟悉 React,但希望把 RSC(React Server Components,React 服务端组件)理解到"能设计系统"的深度。本文面向工程实践,不会回避细节——包括 Flight Protocol 的 wire format 和我自己的判断。
下面的内容分四层:
- 为什么要有 RSC(设计哲学)
- 它到底如何运行(树拆分与流式渲染)
- Flight Protocol 到底传了什么(wire format 逐行拆解)
- 在工程中如何做对(边界声明、动作协议与缓存)
文中嵌入了 RSC Explorer 的示例,你可以直接在结构树上观察 Server/Client 边界。
1. 设计哲学:把 UI 视为"可计算的树"
RSC 的核心不是"更快",而是让 UI 的结构更接近数据与计算的真实形态。在传统 CSR/SSR 中,工程实践常把数据获取、权限判断、视图拼装和交互逻辑混在一起,结果是组件树"长得像页面",但并不"像计算"。RSC 反过来要求你从计算与依赖关系出发构建 UI:组件树首先是计算图,然后才是视觉结构。当你把它当成计算图,你就必须回答一个问题:这段 UI 的数据在哪边?计算在哪边?交互在哪边?
从这个问题出发,RSC 形成了三条"硬哲学"。第一,就近计算(Compute near data)。所有不依赖浏览器能力的计算尽量在服务端完成,因为数据就在这里,权限与安全边界也在这里。第二,交互留给浏览器(Interaction on client)。交互意味着事件、动画、即时状态,它们天然属于浏览器,只应该承载交互而不是数据计算。第三,组件树反映数据依赖(Tree follows data dependencies)。组件的拆分不是"前后端分工",而是"数据从哪来、何时可得"。这条哲学决定了你如何组织组件树:从数据域拆分,而不是从技术域拆分。
这些哲学并不是抽象口号,它们直接改变你对 UI 的组织方式。RSC 把"结构"与"交互"分离,把"数据计算"从"视觉声明"里抽出来,让 UI 变成一棵可推理的树。Server Components 是数据驱动的结构,Client Components 是交互驱动的容器,这不是分工建议,而是架构约束。

图1:设计哲学对比——传统组件混合数据/结构/交互,RSC 分层为 Server 结构与 Client 交互。
2. 树拆分与流式渲染
2.1 树如何拆
RSC 的核心动作不是"渲染",而是把组件树拆成两块并形成稳定的边界。React 在构建树时会识别 use client 边界,把它视为"客户端岛屿"。边界外的所有节点在服务端执行并得到结果,边界内的节点被标记为"客户端可执行模块引用"——它们不会在服务端运行,只会携带必要的 props 穿过边界。于是,树被拆成"可计算的服务端部分"与"需要激活的客户端部分"。
这里的关键不在"把代码拆成两份",而在"把树拆成两段"。你写的组件依旧是一个整体,但在运行时,这棵树被切成了两层:服务端负责生成结构与数据,客户端只激活交互边界。这个切分是可组合的:你可以在任何子树上声明 client 边界,而不是被迫在页面层级整体切分。
服务端执行 Server Components 后,生成一份 RSC payload(下一节详细拆解其 wire format)。客户端接收这份 payload,只对 use client 边界内部进行 hydration——客户端并不"重建整棵树",而是"补全边界内的交互"。

图2:RSC 端到端数据流——请求、服务端执行、payload/HTML、客户端 hydration 与 action 回写。
下面是最小示例(纯 Server Component):
2.2 async 组件与流式渲染
RSC 的天然形态是 async 组件。这里的变化不是"语法更舒服",而是渲染模型发生了变化:组件不再是"同步地返回 JSX",而是"异步地返回结构"。React 可以在服务端对树进行分段计算与流式输出,而不是等待整棵树完成之后再返回。
Suspense 在这里扮演"渲染切分器"。你可以把慢数据的子树包在 Suspense 中,让服务端先输出可用的部分,再随着数据完成逐段补全。这和传统 SSR 不同:SSR 是"拼完整 HTML 再输出",而 RSC 是"拼出树的前半部分就输出,并继续流式补齐"。
从 Flight 协议的视角看,这个流式过程非常具体:服务端先输出已解析的行(已完成的 Server Component 结果),遇到 Suspense 边界时输出一个占位符($Sreact.suspense),然后当被包裹的 async 组件 resolve 后,再追加新的行来替换占位符。客户端的 Flight 解码器是增量式的——它持续消费 stream,每接收到新行就更新对应的树节点。这就是为什么用户能看到页面"逐块出现"而不是整体弹出。
3. Flight Protocol:RSC payload 的 wire format 拆解
这是整篇文章最核心的部分。大多数 RSC 文章会告诉你"服务端生成一份 payload,客户端消费它",然后就停了。但如果你想真正理解 RSC,你必须打开这个黑盒。
3.1 Flight 协议是什么
Flight 是 React 团队设计的一种行式流协议(line-based streaming protocol),用于将服务端组件树的执行结果序列化传输给客户端。它不是 HTML,不是 JSON,也不是任何已有的标准格式——它是一种专门为 React 组件树设计的中间表示。
为什么不用已有格式?这个设计决策值得展开:
为什么不是 HTML? HTML 是 DOM 语义,不是组件语义。一段 HTML 里你无法区分"这是 Server Component 的输出"和"这是 Client Component 的占位"。React 需要在浏览器侧精确地知道哪些节点已经由服务端计算完成、哪些需要客户端接管,HTML 表达不了这个信息。
为什么不是 JSON? JSON 是一次性完整结构,无法原生支持流式传输。服务端可能有多个 async 组件在不同时间 resolve,如果用 JSON,你必须等所有数据就绪才能发送一个完整的 JSON 对象。Flight 的行协议允许逐行追加,每行独立可解析,天然适配流式场景。
为什么不是 Protocol Buffers / MessagePack 等二进制格式? Flight 需要在浏览器中直接解析,且要支持增量解码——一个纯文本的行协议在 ReadableStream 上做 TextDecoder + 按行分割的成本极低,不需要额外的解码库。
所以 Flight 的选择是:文本行协议 + 自定义标记系统 + 行间引用。
3.2 真实 payload 逐行拆解
以下面这个组件结构为例:
// page.tsx (Server Component)
import { Comments } from "./comments";
export default async function Page() {
const article = await getArticle();
return (
<div>
<h1>{article.title}</h1>
<p>{article.body}</p>
<Comments initialCount={article.comments} />
</div>
);
}// comments.tsx (Client Component)
"use client";
import { useState } from "react";
export function Comments({ initialCount }) {
const [count, setCount] = useState(initialCount);
return (
<section>
<span>Comments: {count}</span>
<button onClick={() => setCount((c) => c + 1)}>Add</button>
</section>
);
}在浏览器 Network 面板中拦截 RSC 请求(Next.js 中过滤 content-type: text/x-component),你会看到类似这样的 Flight 响应:
1:I["(app-pages-browser)/./src/comments.tsx",["comments-chunk","static/chunks/comments-abc123.js"],"Comments"]
0:["$","div",null,{"children":[["$","h1",null,{"children":"RSC Tree Structure"}],["$","p",null,{"children":"Server renders structure, client handles interaction."}],["$","$L1",null,{"initialCount":3}]]}]逐行拆解:
第1行——Client Module Reference:
1:I["(app-pages-browser)/./src/comments.tsx",["comments-chunk","static/chunks/comments-abc123.js"],"Comments"]1:— 行 ID,后续行可以用$L1引用这行的结果。I— 类型标记,表示这是一个 Import(客户端模块引用)。- 第一个参数是模块路径(bundler 解析后的标识符)。
- 第二个参数是 chunk 信息(客户端需要加载哪个 JS 文件)。
- 第三个参数是导出名(
"Comments")。
这行的语义是:行 ID 1 代表一个客户端模块,客户端需要加载 comments-abc123.js 并取出 Comments 导出。 服务端不会执行这个组件,只是记录了"它在哪"。
第0行——React Element Tree:
0:["$","div",null,{"children":[["$","h1",null,{"children":"RSC Tree Structure"}],["$","p",null,{"children":"Server renders structure, client handles interaction."}],["$","$L1",null,{"initialCount":3}]]}]0:— 行 ID 0,这是组件树的根。["$","div",null,{...}]— 这是 React element 的序列化形式:$标记表示这是 element,"div"是 type,null是 key,最后是 props。- children 里前两项是普通 HTML 元素(
h1、p),它们的内容已经被服务端完全解析为字符串。 - 关键:
["$","$L1",null,{"initialCount":3}]— 这里的$L1不是一个 HTML 标签,而是对行 ID 1 的惰性引用。它告诉客户端:"这个位置放的是第1行描述的那个客户端模块,props 是{initialCount: 3},请你加载并渲染它。"
这就是 Server / Client 边界在 wire format 层面的真实体现:Server Component 的结果被内联为具体的 element 树,Client Component 被替换为一个引用 + props。
3.3 Flight 类型标记速查
Flight 使用单字符前缀表示行的数据类型,以下是实践中最常遇到的标记:
| 标记 | 含义 | 出现场景 |
|---|---|---|
I | Client module Import | use client 组件的模块引用 |
$ | React element | 所有 JSX 元素的序列化形式 |
$L | Lazy reference | 引用其他行 ID,常见于 client 组件占位 |
$Sreact.suspense | Suspense 占位 | 流式渲染中尚未 resolve 的子树 |
S | Symbol | React 内部 symbol(如 react.fragment) |
HL | Hint — preLoad | 资源预加载提示 |
HM | Hint — preloadModule | 模块预加载提示 |
注意:这些标记不是 public API。React 团队从未将 Flight 的 wire format 作为稳定接口发布。我会在第5节讨论这意味着什么。
3.4 流式场景下的 payload 演变
当 Suspense 介入时,payload 的生成不是一次性的,而是分段追加的。假设 Page 内包裹了一个慢查询:
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<Skeleton />}>
<SlowStats />
</Suspense>
</div>
);
}Flight 响应会先输出:
0:["$","div",null,{"children":[["$","h1",null,{"children":"Dashboard"}],["$","$Sreact.suspense",null,{"fallback":["$","Skeleton",null,{}],"children":"$L1"}]]}]此时 $L1 还没有对应的行——浏览器会先渲染 fallback。当 SlowStats 的数据 resolve 后,服务端追加:
1:["$","section",null,{"children":[["$","span",null,{"children":"Total: 1,234"}]]}]客户端 Flight 解码器接收到这行后,发现行 ID 1 对应的是之前 $L1 的占位位置,于是用新内容替换 fallback。这就是 RSC streaming 的底层机制——不是"重新发送整棵树",而是"追加缺失的行"。
3.5 与 AI SDK streamUI 的同构性
这里有一个值得指出的事实:Vercel AI SDK 的 streamUI 和 createStreamableValue 使用的底层传输机制就是 Flight protocol。当你用 AI SDK 做 Generative UI(服务端根据 LLM 输出动态决定返回哪个 React 组件),它的 wire format 和上面拆解的完全一致——LLM 的每一次 token 输出触发服务端追加新的 Flight 行,客户端增量解码并更新 UI。
RSC 的流式设计在被发明时可能并没有考虑 AI 场景,但它的"逐行追加、增量解码、Suspense 占位"机制恰好是 AI streaming UI 的理想传输层。这不完全是巧合——React 团队设计 Flight 时解决的是"服务端异步数据逐步就绪"的问题,而 LLM 的 token-by-token 输出本质上就是同一个问题的极端形式。

图3:RSC payload 结构——树节点、client 引用、惰性占位与 props 序列化。

图4:Flight 分片流式传输时序——Suspense 占位、行追加与客户端增量解码。
4. 边界声明、动作协议与缓存
4.1 use client:语义边界,不是性能开关
use client 的作用不是"让它更快",而是声明:这块树由客户端负责。从 Flight 协议的角度看,这条指令的效果非常具体:它让 React 在序列化时把该组件替换为一个 I(Import)类型的行,而不是展开执行。边界内部无法访问服务端能力,边界之外也无法依赖浏览器状态。
工程上更重要的是:use client 直接决定了 Flight payload 的体积。边界越大,I 行越少但 props 越重(因为服务端需要把更多数据序列化传给客户端);边界越小越精确,payload 越轻但组件拆分越碎。真正的优化不是"少写 use client",而是"把它放在正确的粒度"。
4.2 use server:跨边界的动作协议
use server 是一种调用协议:浏览器发起动作,服务器执行并返回结果。它不是组件,而是"可被客户端触发的服务端函数"。从架构角度看,Server Actions 就是应用层的 RPC 端点——只是 React 帮你省掉了手写路由、序列化和错误处理的样板代码。
// app/(admin)/posts/actions.ts
"use server";
export async function savePost(formData: FormData) {
const title = String(formData.get("title") ?? "");
const body = String(formData.get("body") ?? "");
// 写入数据库或文件系统
}// components/post-editor.tsx
"use client";
export function PostEditor({ onSave }: { onSave: (fd: FormData) => Promise<void> }) {
return (
<form action={onSave}>
<input name="title" placeholder="Post title…" />
<textarea name="body" placeholder="Write something…" />
<button type="submit">Save</button>
</form>
);
}把 Server Actions 视作应用层 API 意味着你应该对它们做和 API 路由同样的事情:输入校验、错误处理、幂等设计、日志审计。React 省掉的是 plumbing,不是工程纪律。
4.3 use cache:让服务端计算变得可复用
use cache 将某段 server 计算显式定义为可缓存。它不是"优化细节",而是影响你树的计算频率的架构决策。
export async function getPostList() {
"use cache";
return await fetchPosts();
}在 Next.js 中,缓存控制还能通过 cacheTag / cacheLife 做细粒度失效。实践上有一条铁律:写操作必须显式触发失效(revalidateTag / revalidatePath),否则缓存会让 UI "看起来没更新"——这不是 bug,这是你没有正确声明数据的生命周期。
5. 结构示例:一棵合理的 RSC 树
一个健康的 RSC 结构通常是:上层都是 Server,局部边界用 Client 隔开交互。
Page (Server)
├─ Shell (Server)
│ ├─ Sidebar (Server)
│ ├─ Content (Server)
│ │ ├─ Article (Server)
│ │ └─ Comments (Client) ← 唯一的 use client 边界
│ └─ Footer (Server)
└─ Toasts (Client) ← 全局交互层对照 Flight payload 的视角:这棵树中 Shell、Sidebar、Content、Article、Footer 全部会在服务端执行并内联为具体的 $ element 行,只有 Comments 和 Toasts 会出现 I 行(module reference)和 $L 惰性引用。客户端只需要加载和 hydrate 这两个组件的 JS——其余部分是零 JS 成本的。
6. 我的判断:RSC 的设计权衡与未解决问题
写完原理,我想说几个不会出现在官方文档里的观点。
Flight Protocol 是一个没有 spec 的协议。 截至目前,React 团队从未将 Flight 的 wire format 作为稳定的 public API 发布。上面拆解的所有标记(I、$L、$S 等)都可能在下一个 React 版本中改变。这意味着:所有对 RSC 的框架级集成(Next.js、Waku、RedwoodJS)本质上都在追一个 moving target。如果你想基于 Flight payload 做调试工具、中间件或自定义传输层,你需要接受"随时可能 break"这个前提。React 团队的态度是"先让语义稳定,wire format 以后再说"——这在工程上是务实的,但也意味着 RSC 生态的成熟度比它的使用量暗示的要低。
use client 的序列化约束在复杂场景下是痛苦的。 理论上"只传可序列化数据"是优雅的约束,但实践中你会遇到很多灰色地带:Date 对象不可直接序列化(需要转成 string 再在客户端 parse back)、Map / Set 不行、RegExp 不行、甚至 undefined 在 JSON 序列化中的行为也和你预期不同。如果你的 Server Component 从数据库拿到的对象带有这些类型,你必须在边界处做一层手动转换——这个"类型适配层"在中大型项目里会变成真实的维护负担。React 团队通过扩展序列化器(支持 Date、Map、Set 等)在逐步缓解这个问题,但边界类型安全仍然依赖开发者的自觉,TypeScript 目前还无法在编译期捕获"跨边界传了不可序列化对象"这类错误。
RSC 让"服务端是默认"成为合理的心智模型,但这个默认值对新手是陷阱。 "所有组件默认是 Server Component"这个设定,对理解 RSC 的人来说是减少样板代码的好设计,但对不理解的人来说,它制造了一种隐式的、难以调试的错误模式——你在 Server Component 里写了 useState,报错信息并不直接告诉你"这里不是客户端",而是给你一个相对晦涩的 React 内部错误。Next.js 在这方面的 DX 改进了很多,但根本问题仍然在:"默认是 server"这个设计决策,把理解 RSC 从"可选的高级知识"变成了"必须掌握的前置条件"。 这不是坏事,但它提高了 React 的入门门槛。
7. 小结
RSC 是一种树的组织方式,Flight Protocol 是这棵树的传输语言。
Server Components 负责结构与数据,Client Components 负责交互。use client 是序列化边界,use server 是调用协议,use cache 是计算复用声明。Flight 用行式流协议把这一切编码成可增量解码的传输格式,让服务端的异步计算能逐段到达浏览器。
当你开始从 Flight payload 的视角审视你的组件树——哪些节点被内联为 $ element、哪些被替换为 $L 引用、哪些在等待 Suspense 行追加——你对 RSC 的理解就从"概念层"进入了"机制层"。这时候你做的架构决策才是有依据的,而不是在遵循一套你并不完全理解的最佳实践。