发布时间

React Server Components 设计哲学、原理与最佳实践

发布时间

篇幅

4,686 字数

~ 22 分钟阅读

从设计哲学到 Flight Protocol 的 wire format 拆解,再到 use server / use client / use cache 的实践与边界,一次讲清 RSC。

ReactRSCNext.jsArchitectureServer ComponentsFlight Protocol

读者定位:熟悉 React,但希望把 RSC(React Server Components,React 服务端组件)理解到"能设计系统"的深度。本文面向工程实践,不会回避细节——包括 Flight Protocol 的 wire format 和我自己的判断。

下面的内容分四层:

  1. 为什么要有 RSC(设计哲学)
  2. 它到底如何运行(树拆分与流式渲染)
  3. Flight Protocol 到底传了什么(wire format 逐行拆解)
  4. 在工程中如何做对(边界声明、动作协议与缓存)

文中嵌入了 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 是交互驱动的容器,这不是分工建议,而是架构约束。

RSC 设计哲学对比图:传统组件混合数据与交互,RSC 分层为 Server 结构与 Client 交互

图1:设计哲学对比——传统组件混合数据/结构/交互,RSC 分层为 Server 结构与 Client 交互。


2. 树拆分与流式渲染

2.1 树如何拆

RSC 的核心动作不是"渲染",而是把组件树拆成两块并形成稳定的边界。React 在构建树时会识别 use client 边界,把它视为"客户端岛屿"。边界外的所有节点在服务端执行并得到结果,边界内的节点被标记为"客户端可执行模块引用"——它们不会在服务端运行,只会携带必要的 props 穿过边界。于是,树被拆成"可计算的服务端部分"与"需要激活的客户端部分"

这里的关键不在"把代码拆成两份",而在"把树拆成两段"。你写的组件依旧是一个整体,但在运行时,这棵树被切成了两层:服务端负责生成结构与数据,客户端只激活交互边界。这个切分是可组合的:你可以在任何子树上声明 client 边界,而不是被迫在页面层级整体切分。

服务端执行 Server Components 后,生成一份 RSC payload(下一节详细拆解其 wire format)。客户端接收这份 payload,只对 use client 边界内部进行 hydration——客户端并不"重建整棵树",而是"补全边界内的交互"。

RSC 端到端数据流:请求、服务端执行、生成 payload 与 HTML、浏览器接收、客户端 hydration、server actions 回写

图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 逐行拆解

以下面这个组件结构为例:

code
// 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>
  );
}
code
// 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 响应:

code
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:

code
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:

code
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 元素(h1p),它们的内容已经被服务端完全解析为字符串。
  • 关键["$","$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 使用单字符前缀表示行的数据类型,以下是实践中最常遇到的标记:

标记含义出现场景
IClient module Importuse client 组件的模块引用
$React element所有 JSX 元素的序列化形式
$LLazy reference引用其他行 ID,常见于 client 组件占位
$Sreact.suspenseSuspense 占位流式渲染中尚未 resolve 的子树
SSymbolReact 内部 symbol(如 react.fragment
HLHint — preLoad资源预加载提示
HMHint — preloadModule模块预加载提示

注意:这些标记不是 public API。React 团队从未将 Flight 的 wire format 作为稳定接口发布。我会在第5节讨论这意味着什么。

3.4 流式场景下的 payload 演变

当 Suspense 介入时,payload 的生成不是一次性的,而是分段追加的。假设 Page 内包裹了一个慢查询:

code
export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<Skeleton />}>
        <SlowStats />
      </Suspense>
    </div>
  );
}

Flight 响应会先输出:

code
0:["$","div",null,{"children":[["$","h1",null,{"children":"Dashboard"}],["$","$Sreact.suspense",null,{"fallback":["$","Skeleton",null,{}],"children":"$L1"}]]}]

此时 $L1 还没有对应的行——浏览器会先渲染 fallback。当 SlowStats 的数据 resolve 后,服务端追加:

code
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 的 streamUIcreateStreamableValue 使用的底层传输机制就是 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 输出本质上就是同一个问题的极端形式。

RSC payload 结构示意:树节点、文本节点、client 引用与 props 序列化

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

服务端序列化到客户端反序列化的时序:执行、序列化分片、流式传输、客户端解码与 hydration

图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 帮你省掉了手写路由、序列化和错误处理的样板代码。

code
// 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") ?? "");
  // 写入数据库或文件系统
}
code
// 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 计算显式定义为可缓存。它不是"优化细节",而是影响你树的计算频率的架构决策。

code
export async function getPostList() {
  "use cache";
  return await fetchPosts();
}

在 Next.js 中,缓存控制还能通过 cacheTag / cacheLife 做细粒度失效。实践上有一条铁律:写操作必须显式触发失效revalidateTag / revalidatePath),否则缓存会让 UI "看起来没更新"——这不是 bug,这是你没有正确声明数据的生命周期。


5. 结构示例:一棵合理的 RSC 树

一个健康的 RSC 结构通常是:上层都是 Server,局部边界用 Client 隔开交互

code
Page (Server)
├─ Shell (Server)
│  ├─ Sidebar (Server)
│  ├─ Content (Server)
│  │  ├─ Article (Server)
│  │  └─ Comments (Client)   ← 唯一的 use client 边界
│  └─ Footer (Server)
└─ Toasts (Client)            ← 全局交互层

对照 Flight payload 的视角:这棵树中 ShellSidebarContentArticleFooter 全部会在服务端执行并内联为具体的 $ element 行,只有 CommentsToasts 会出现 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 团队通过扩展序列化器(支持 DateMapSet 等)在逐步缓解这个问题,但边界类型安全仍然依赖开发者的自觉,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 的理解就从"概念层"进入了"机制层"。这时候你做的架构决策才是有依据的,而不是在遵循一套你并不完全理解的最佳实践。