Appearance
第12章 React Server Components 架构
本章要点
- RSC 的设计动机:零 bundle size 组件如何从根本上改变前端架构
- Server Component 与 Client Component 的边界划分规则与
"use client"指令的编译时处理- RSC Wire Protocol(Flight 协议):服务端如何将组件树序列化为流式传输格式
renderToPipeableStream与流式 SSR 的底层实现原理- RSC Payload 的数据结构:行格式、类型标记与引用模型
- RSC 与 Next.js App Router 的深度集成机制
- RSC 的性能模型:何时用、何时不用的工程决策框架
在 React 的演进历程中,有几个里程碑式的转折点:2015 年的 Virtual DOM 重新定义了 UI 编程模型,2017 年的 Fiber 架构重写了渲染引擎,2022 年的并发模式让渲染变得可中断。而 React Server Components(RSC)代表着第四次范式跃迁——它模糊了服务端与客户端的物理边界,让组件不再被困在浏览器这个"沙盒"里。
这次变革的激进程度超出了大多数人的预期。在传统的 React 应用中,所有组件都运行在浏览器端——即使一个组件只是从数据库读取数据然后渲染静态文本,它的代码也必须被打包、传输、解析、执行。RSC 的核心洞察是:不是每个组件都需要交互能力,而没有交互能力的组件没有理由运行在客户端。这不是一个渐进式的优化,而是对"组件在哪里运行"这个根本问题的重新回答。
本章将从源码层面解剖 RSC 的完整架构:从 "use client" 指令的编译时处理,到 Flight 协议的序列化机制,再到流式渲染的管道实现。我们不仅要理解它如何工作,更要理解它为什么必须这样设计。
12.1 RSC 的设计动机:零 bundle size 的组件
12.1.1 传统 SSR 的困境
在 RSC 出现之前,React 的服务端渲染(SSR)已经存在多年。但传统 SSR 有一个被广泛忽视的结构性问题:它只是把首屏渲染搬到了服务端,组件代码本身仍然会全部发送到客户端。
typescript
// 传统 SSR 的流程
// 步骤 1: 服务端渲染 HTML
const html = renderToString(<App />);
// 步骤 2: 将 HTML 发送到客户端
res.send(`
<html>
<body>${html}</body>
<script src="/bundle.js"></script> <!-- 全部组件代码 -->
</html>
`);
// 步骤 3: 客户端加载 bundle.js,执行 hydration
// 即使 <StaticHeader /> 永远不会更新,它的代码也在 bundle.js 中
hydrateRoot(document.getElementById('root'), <App />);这里存在一个深层矛盾:SSR 的价值在于"用户更早看到内容",但客户端仍然需要下载全部 JavaScript 才能完成 hydration。对于一个包含大量静态内容的页面(博客文章、产品详情、文档页面),bundle 中可能有超过 60% 的代码只是为了在客户端"重新生成"服务端已经渲染过的内容。
下图对比了传统 SSR 与 RSC 架构下的数据传输差异:
传统 SSR 的传输量 = HTML + 全部组件代码 + 全部依赖库。而 RSC 的传输量 = RSC Payload + 仅 Client Component 代码。Server Component 的代码和依赖永远不离开服务端。
12.1.2 零 bundle size 的数学本质
RSC 的"零 bundle size"并不是一个营销口号,而是一个可以量化的工程指标。让我们用一个典型的电商产品页面来说明:
tsx
// 产品详情页 — 未使用 RSC
// 客户端 bundle 包含:
// - react-markdown (92KB gzipped)
// - date-fns (16KB gzipped)
// - sanitize-html (48KB gzipped)
// - highlight.js (68KB gzipped)
// 总计:仅第三方依赖就约 224KB
import ReactMarkdown from 'react-markdown';
import { format } from 'date-fns';
import sanitizeHtml from 'sanitize-html';
import hljs from 'highlight.js';
function ProductDescription({ product }: { product: Product }) {
const cleanHtml = sanitizeHtml(product.description);
const formattedDate = format(product.createdAt, 'yyyy-MM-dd');
return (
<div>
<h1>{product.name}</h1>
<time>{formattedDate}</time>
<ReactMarkdown>{cleanHtml}</ReactMarkdown>
<pre>
<code dangerouslySetInnerHTML={{
__html: hljs.highlight(product.codeExample, { language: 'tsx' }).value
}} />
</pre>
</div>
);
}在 RSC 架构下,ProductDescription 是一个 Server Component——它在服务端渲染成最终的 HTML/React 元素,react-markdown、date-fns、sanitize-html、highlight.js 这些依赖永远不会出现在客户端 bundle 中。这不是 tree-shaking,不是 code-splitting——这些库的代码从物理上就不存在于客户端的网络传输中。
12.1.3 不止是体积:直接访问后端资源
零 bundle size 只是 RSC 带来的第一层价值。更深层的价值在于:Server Component 可以直接访问服务端资源,而不需要通过 API 中间层。
tsx
// Server Component — 直接访问数据库
// 这段代码只在服务端运行,永远不会出现在客户端 bundle 中
import { db } from '@/lib/database';
import { cache } from 'react';
// React 的 cache() 会对同一次渲染中的重复调用去重
const getProduct = cache(async (id: string) => {
// 直接执行 SQL 查询,无需 REST/GraphQL 中间层
const product = await db.query(
'SELECT * FROM products WHERE id = $1',
[id]
);
return product;
});
async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
// 注意:这是一个 async 函数组件
// 在传统 React 中,这是不可能的
// 在 RSC 中,这是最自然的编程模型
return (
<div>
<ProductHeader product={product} />
<ProductReviews productId={product.id} />
<AddToCartButton productId={product.id} /> {/* Client Component */}
</div>
);
}深度洞察:RSC 本质上是对"前后端分离"这个十年来被奉为金科玉律的架构范式的一次"逆反"。它的核心论点是:前后端分离在 API 层面是必要的(你需要独立部署和扩缩容),但在组件层面是多余的(一个只读数据展示的组件,没有理由在客户端重新运行)。RSC 把"分离的粒度"从"整个应用"细化到了"单个组件"。
12.2 Server Component vs Client Component:边界划分的艺术
12.2.1 "use client" 指令的编译时语义
"use client" 不是一个运行时的 API,而是一个编译器指令(compiler directive)。它的语义是:"从这个文件开始,以及它导入的所有文件,都是客户端代码"。
tsx
// components/AddToCartButton.tsx
"use client"; // 编译器边界标记
import { useState, useTransition } from 'react';
import { addToCart } from '@/actions/cart';
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
const [quantity, setQuantity] = useState(1);
return (
<div>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await addToCart(productId, quantity);
});
}}
>
{isPending ? '添加中...' : '加入购物车'}
</button>
</div>
);
}从编译器的视角看,"use client" 指令在模块依赖图中创建了一条分割线:
上方是 Server Component 区域,下方是 Client Component 区域。服务端 bundle 中只保留对 Client Component 的引用,而不包含其实际代码。
12.2.2 边界处理的编译过程
当 bundler(如 webpack 的 react-server-dom-webpack 插件)遇到 "use client" 指令时,它不会将该模块内联到服务端 bundle 中。相反,它会生成一个模块引用(Module Reference):
typescript
// 编译器处理 "use client" 的简化逻辑
function processClientBoundary(modulePath: string, exportName: string) {
// 不将实际代码打包到服务端 bundle
// 而是生成一个引用对象
return {
$$typeof: Symbol.for('react.client.reference'),
$$id: `${modulePath}#${exportName}`,
// 这个 ID 将用于客户端加载对应的 chunk
};
}
// 在服务端 bundle 中,AddToCartButton 变成了:
// (不是组件函数,而是一个引用标记)
const AddToCartButton = {
$$typeof: Symbol.for('react.client.reference'),
$$id: '/components/AddToCartButton.tsx#AddToCartButton',
};这个设计非常精妙。服务端渲染 ProductPage 时遇到 <AddToCartButton />,它不会尝试执行这个组件(因为它只是一个引用标记),而是将这个引用及其 props 序列化到 RSC Payload 中,交由客户端处理。
12.2.3 组合模式:Server Component 与 Client Component 的嵌套规则
理解嵌套规则是正确使用 RSC 的关键。核心规则只有两条:
规则一:Server Component 可以导入和渲染 Client Component。
tsx
// ✅ Server Component 渲染 Client Component
// app/page.tsx (Server Component)
import { AddToCartButton } from '@/components/AddToCartButton'; // "use client"
async function ProductPage() {
const product = await db.products.findOne({ id: '123' });
return (
<div>
<h1>{product.name}</h1>
<AddToCartButton productId={product.id} />
</div>
);
}规则二:Client Component 不能导入 Server Component,但可以通过 children 或其他 props 接收 Server Component 的渲染结果。
tsx
// ❌ 这是不允许的
"use client";
import { ServerOnlyComponent } from './ServerOnlyComponent'; // 错误!
function ClientWrapper() {
return <ServerOnlyComponent />; // Server Component 不能在客户端运行
}
// ✅ 正确的做法:通过 children 传递
"use client";
function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return isOpen ? <div className="panel">{children}</div> : null;
}
// 在 Server Component 中组合
async function Page() {
const data = await fetchData();
return (
<ClientWrapper>
{/* ServerContent 的渲染结果作为 children 传入 */}
<ServerContent data={data} />
</ClientWrapper>
);
}这个模式之所以可行,是因为 React 的渲染是自顶向下的。当服务端渲染 Page 时,它会先渲染 ServerContent 得到 React 元素树,然后将这个已渲染的结果作为 children prop 传递给 ClientWrapper 的引用。客户端接收到的是已经序列化的元素树,不需要执行任何 Server Component 代码。
12.2.4 边界划分的工程决策框架
在实践中,决定一个组件应该是 Server 还是 Client,需要考虑以下决策矩阵:
- Server Component:可直接访问数据库/文件系统,支持 async/await,不加入客户端 bundle,但不能使用 useState/useEffect、事件处理、浏览器 API
- Client Component:支持全部 React Hooks 和浏览器 API、事件处理,但会加入客户端 bundle,不能直接访问服务端资源
深度洞察:
"use client"指令的位置选择,本质上是在回答一个架构问题——"交互性从组件树的哪一层开始?" 最佳实践是将这条线推得尽可能靠近叶子节点。一个常见的反模式是在布局层级就标记"use client",导致整棵子树都被拉到客户端。正确的做法是:将交互逻辑封装在最小的 Client Component 中,而将数据获取和渲染逻辑留在 Server Component。
12.3 RSC Wire Protocol:服务端如何序列化组件树
12.3.1 Flight 协议概述
RSC 的网络传输不使用 HTML,也不使用 JSON——它使用一种专门设计的行文本协议,React 团队内部称之为 Flight 协议。这个协议的设计目标是支持流式传输(streaming),使得客户端可以在服务端还在渲染时就开始增量地处理数据。
Flight 协议的每一行代表一个"数据块"(chunk),格式为:
<行 ID>:<类型标记><数据>\n让我们看一个具体的例子。假设服务端渲染以下组件树:
tsx
// app/page.tsx (Server Component)
async function Page() {
const posts = await db.posts.findMany();
return (
<Layout>
<h1>博客</h1>
<PostList posts={posts} />
<SearchBar /> {/* Client Component */}
</Layout>
);
}生成的 RSC Payload(简化后)大致如下:
0:["$","div",null,{"className":"layout","children":[["$","h1",null,{"children":"博客"}],["$","div",null,{"className":"post-list","children":[["$","article","post-1",{"children":[["$","h2",null,{"children":"第一篇文章"}],["$","p",null,{"children":"内容摘要..."}]]}],["$","article","post-2",{"children":[["$","h2",null,{"children":"第二篇文章"}],["$","p",null,{"children":"内容摘要..."}]]}]]}],["$","@1",null,{}]]}]
1:I["components/SearchBar.tsx","SearchBar"]