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>
);
}