Skip to content

第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-markdowndate-fnssanitize-htmlhighlight.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"]

基于 VitePress 构建