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

基于 VitePress 构建