React 19 内核探秘

第12章 React Server Components 架构

作者 杨艺韬 · 10,938 字

第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 有一个被广泛忽视的结构性问题:它只是把首屏渲染搬到了服务端,组件代码本身仍然会全部发送到客户端

// 传统 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 中可能有很大一部分代码只是为了在客户端”重新生成”服务端已经渲染过的内容。

下图对比了传统 SSR 与 RSC 架构下的数据传输差异:

graph LR
    subgraph SSR["传统 SSR"]
        S1["服务端渲染 HTML"] --> S2["发送 HTML"]
        S2 --> S3["发送全部 JS Bundle<br/>包含所有组件代码"]
        S3 --> S4["客户端 Hydration<br/>重新执行所有组件"]
    end

    subgraph RSC["RSC 架构"]
        R1["Server Component<br/>在服务端执行"] --> R2["发送 RSC Payload<br/>序列化的元素树"]
        R3["Client Component<br/>代码仅包含交互组件"] --> R4["客户端渲染<br/>只 hydrate 交互部分"]
        R2 --> R4
    end

    style SSR fill:#ffebee,stroke:#c62828
    style RSC fill:#e8f5e9,stroke:#2e7d32

传统 SSR 的传输量 = HTML + 全部组件代码 + 全部依赖库。而 RSC 的传输量 = RSC Payload + 仅 Client Component 代码。Server Component 的代码和依赖永远不离开服务端。

12.1.2 零 bundle size 的数学本质

RSC 的”零 bundle size”并不是一个营销口号,而是一个可以量化的工程指标。让我们用一个典型的电商产品页面来说明:

// 产品详情页 — 未使用 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 中间层。

// 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)。它的语义是:“从这个文件开始,以及它导入的所有文件,都是客户端代码”。

// 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" 指令在模块依赖图中创建了一条分割线:

graph TD
    subgraph Server["服务端 Bundle"]
        PP["ProductPage.tsx<br/>Server Component"] --> PH["ProductHeader.tsx<br/>Server Component"]
        PP --> PR["ProductReviews.tsx<br/>Server Component"]
        PP --> REF["AddToCartButton 引用<br/>$$typeof: react.client.reference<br/>$$id: 模块路径#导出名"]
    end

    subgraph 边界["use client 分割线"]
        BORDER["--- use client ---"]
    end

    subgraph Client["客户端 Bundle"]
        ACB["AddToCartButton.tsx<br/>Client Component"]
        ACB --> UST["useState"]
        ACB --> UTR["useTransition"]
    end

    REF -.->|"运行时加载"| ACB

    style Server fill:#e3f2fd,stroke:#1565c0
    style Client fill:#fff3e0,stroke:#e65100
    style 边界 fill:#ffcdd2,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5

上方是 Server Component 区域,下方是 Client Component 区域。服务端 bundle 中只保留对 Client Component 的引用,而不包含其实际代码。

12.2.2 边界处理的编译过程

当 bundler(如 webpack 的 react-server-dom-webpack 插件)遇到 "use client" 指令时,它不会将该模块内联到服务端 bundle 中。相反,它会生成一个模块引用(Module Reference)

// 编译器处理 "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。

// ✅ 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 的渲染结果。

// ❌ 这是不允许的
"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

让我们看一个具体的例子。假设服务端渲染以下组件树:

// 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"]

下图展示了 Flight 协议从服务端到客户端的流式传输过程:

sequenceDiagram
    participant S as 服务端
    participant N as 网络(流式传输)
    participant C as 客户端

    S->>S: 渲染 Server Component 树
    S->>N: 行0: 已渲染的元素树(JSON)
    N->>C: 增量接收,开始构建 React 树
    S->>S: 遇到 Client Component 引用
    S->>N: 行1: I["模块路径","导出名"]
    N->>C: 加载对应 Client Component chunk
    S->>S: 遇到 Suspense 等待的数据
    Note over S: 继续渲染其他部分
    S->>N: 行2: 其他已完成的数据块
    N->>C: 更新已渲染的部分
    S->>S: Suspense 数据就绪
    S->>N: 行3: Suspense 完成的子树
    N->>C: 替换 fallback 显示真实内容

12.3.2 RSC Payload 的类型标记系统

Flight 协议使用一组类型标记来区分不同类型的数据块:

// react-server-dom-webpack/src/ReactFlightServerConfig.js
// Flight 协议的核心类型标记

// 行类型前缀(简化表示)
const ROW_TYPE = {
  MODEL:    '',   // 默认:React 元素模型(JSON 编码的 React 树)
  MODULE:   'I',  // Client Component 模块引用 (Import)
  HINT:     'H',  // 预加载提示(CSS、字体等资源)
  ERROR:    'E',  // 错误信息
  TEXT:     'T',  // 纯文本块
  BLOCKED:  'B',  // 被阻塞的块(等待异步操作)
  POSTPONE: 'P',  // 延迟渲染标记
};

// 在 Payload 中,React 元素使用特殊的 $ 标记:
// "$"        → React 元素 (createElement)
// "$L<id>"   → 懒加载引用 (lazy reference)
// "$F"       → Server Component 的引用 (Flight)
// "@<id>"    → Client Component 引用(指向 I 行定义的模块)
// "$undefined" → undefined 值
// "$Infinity"  → Infinity
// "$-Infinity" → -Infinity
// "$NaN"       → NaN
// "$-0"        → -0

12.3.3 序列化引擎的实现

RSC 的序列化引擎位于 react-server 包中,其核心是 renderToReadableStream(Web Streams)或 renderToPipeableStream(Node.js Streams)。让我们深入其内部实现:

// react-server/src/ReactFlightServer.js(简化)

// RSC 渲染请求的核心数据结构
type Request = {
  destination: Destination;       // 输出流
  bundlerConfig: BundlerConfig;   // 用于解析 Client Component 引用
  cache: Map<Function, mixed>;    // 服务端缓存
  nextChunkId: number;            // 递增的行 ID
  pendingChunks: number;          // 未完成的异步块计数
  completedModuleChunks: Array<Chunk>; // 已完成的模块引用块
  completedJSONChunks: Array<Chunk>;   // 已完成的 JSON 块
  completedErrorChunks: Array<Chunk>;  // 错误块
  abortableTasks: Set<Task>;           // 可中止的任务集
};

function renderToReadableStream(
  model: ReactClientValue,
  bundlerConfig: BundlerConfig,
  options?: Options
): ReadableStream {
  const request = createRequest(model, bundlerConfig, options);

  const stream = new ReadableStream({
    start(controller) {
      // 开始渲染流程
      startWork(request);
    },
    pull(controller) {
      // 消费端请求更多数据时,刷新待发送的块
      startFlowing(request, controller);
    },
    cancel(reason) {
      // 流被取消时,中止所有待处理任务
      abort(request, reason);
    },
  });

  return stream;
}

序列化的核心是 resolveModelToJSON 函数,它递归地处理 React 元素树:

// 简化的序列化逻辑
function resolveModelToJSON(
  request: Request,
  parent: { [key: string]: ReactClientValue },
  key: string,
  value: ReactClientValue
): ReactJSONValue {

  // 处理 React 元素
  if (
    typeof value === 'object' &&
    value !== null &&
    value.$$typeof === REACT_ELEMENT_TYPE
  ) {
    const element = value;

    if (typeof element.type === 'function') {
      // Server Component:直接执行函数,获取渲染结果
      // 这是 RSC 的核心——服务端调用组件函数
      try {
        const result = element.type(element.props);

        // 如果返回 Promise(async 组件),创建一个挂起的任务
        if (typeof result === 'object' && result !== null &&
            typeof result.then === 'function') {
          // 创建新的行 ID,标记为 pending
          const newTask = createTask(request, result, element.type);
          request.pendingChunks++;
          request.abortableTasks.add(newTask);

          // 返回延迟引用
          return serializeLazyID(newTask.id);
        }

        // 同步组件:递归处理返回结果
        return resolveModelToJSON(request, parent, key, result);
      } catch (thrownValue) {
        // 处理 Suspense throw
        if (typeof thrownValue === 'object' &&
            typeof thrownValue.then === 'function') {
          const newTask = createTask(request, thrownValue, element.type);
          request.pendingChunks++;
          return serializeLazyID(newTask.id);
        }
        throw thrownValue;
      }
    }

    if (typeof element.type === 'string') {
      // 宿主元素(div, span 等):序列化为 ["$", type, key, props]
      return ['$', element.type, element.key, element.props];
    }

    if (isClientReference(element.type)) {
      // Client Component 引用:序列化模块信息
      const moduleId = resolveClientReferenceMetadata(
        request.bundlerConfig,
        element.type
      );
      // 输出 I 行:模块引用
      emitModuleChunk(request, moduleId);
      // 在元素树中使用 @ 引用
      return ['$', `@${moduleId.id}`, element.key, element.props];
    }
  }

  // 处理 Promise
  if (typeof value === 'object' && value !== null &&
      typeof value.then === 'function') {
    const promiseId = request.nextChunkId++;
    request.pendingChunks++;

    value.then(
      (resolvedValue) => {
        const processedChunk = processModelChunk(
          request, promiseId, resolvedValue
        );
        request.completedJSONChunks.push(processedChunk);
        flushCompletedChunks(request);
      },
      (reason) => {
        emitErrorChunk(request, promiseId, reason);
      }
    );

    return serializeLazyID(promiseId);
  }

  // 基本类型直接返回
  return value;
}

12.3.4 不可序列化值的处理策略

Server Component 的 props 必须是可序列化的,因为它们要通过 Flight 协议传输。以下类型会触发序列化错误:

// 可以通过 Flight 协议序列化的类型
type SerializableValue =
  | string
  | number
  | boolean
  | null
  | undefined
  | bigint
  | Array<SerializableValue>
  | { [key: string]: SerializableValue }
  | Date
  | Map<SerializableValue, SerializableValue>
  | Set<SerializableValue>
  | FormData
  | ReadableStream
  | URL
  | URLSearchParams
  | RegExp
  | Promise<SerializableValue>
  | ReactElement           // 包含 Server/Client Component
  | ClientReference;       // "use client" 标记的引用

// 不可序列化(将报错)的类型
// ❌ 函数(除了 Client Reference 和 Server Action)
// ❌ Class 实例
// ❌ Symbol(除了 React 内部使用的)
// ❌ 包含循环引用的对象

// 特殊情况:Server Action 可以序列化
// 它们与 Client Reference 类似,被转换为引用 ID
async function deletePost(formData: FormData) {
  "use server";  // 这个函数可以作为 prop 传递给 Client Component
  const id = formData.get('id');
  await db.posts.delete({ where: { id } });
}

深度洞察:Flight 协议的设计体现了一个重要的工程取舍——它选择了”行文本”而不是二进制格式。这看似低效,实际上是为了流式解析的简单性:每当收到一个换行符,客户端就可以立即解析并处理该行数据,无需维护复杂的二进制分帧状态机。HTTP/2 和 HTTP/3 的帧层已经提供了高效的二进制传输,应用层协议的简单性比压缩效率更有价值。

12.4 流式渲染(Streaming SSR)的实现原理

12.4.1 从 renderToStringrenderToPipeableStream

React 18 引入了 renderToPipeableStream,它与传统的 renderToString 有根本性的区别:

// 传统方式:等待全部渲染完成后一次性输出
// 如果有一个慢查询(如3秒),整个页面都被阻塞
import { renderToString } from 'react-dom/server';

app.get('/', (req, res) => {
  // ⚠️ 阻塞直到所有数据就绪
  const html = renderToString(<App />);
  res.send(html);
});

// 流式方式:边渲染边输出
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(
    <App />,
    {
      bootstrapScripts: ['/client.js'],
      onShellReady() {
        // Shell(非 Suspense 部分)准备就绪,开始发送
        res.statusCode = 200;
        res.setHeader('Content-Type', 'text/html');
        pipe(res);  // 开始流式传输
      },
      onShellError(error) {
        // Shell 渲染失败,发送降级方案
        res.statusCode = 500;
        res.send('<h1>服务器错误</h1>');
      },
      onAllReady() {
        // 所有内容(包括 Suspense 内容)都渲染完成
        // 用于爬虫/SSG 场景
      },
      onError(error) {
        console.error(error);
      },
    }
  );

  // 超时处理
  setTimeout(() => abort(), 10000);
});

12.4.2 Shell 与 Suspense 边界的流式交互

流式渲染的核心机制是将页面分为”Shell”和若干个”Suspense 岛屿”。Shell 是立即可用的 UI 骨架,Suspense 边界包裹的内容在数据就绪后逐块注入。

// 服务端组件树
async function Page() {
  return (
    <html>
      <body>
        {/* Shell 部分 — 立即发送 */}
        <Header />
        <nav><Sidebar /></nav>

        <main>
          {/* Suspense 岛屿 1 — 数据就绪后注入 */}
          <Suspense fallback={<PostsSkeleton />}>
            <PostList />  {/* async: 需要查数据库 */}
          </Suspense>

          {/* Suspense 岛屿 2 — 独立的数据流 */}
          <Suspense fallback={<CommentsSkeleton />}>
            <RecentComments />  {/* async: 调用外部 API */}
          </Suspense>
        </main>

        <Footer />
      </body>
    </html>
  );
}

浏览器收到的 HTML 流大致如下:

<!-- 第一块:Shell(立即发送) -->
<!DOCTYPE html>
<html>
<body>
  <header><!-- Header 内容 --></header>
  <nav><!-- Sidebar 内容 --></nav>
  <main>
    <!-- Suspense fallback 作为占位符 -->
    <template id="B:0"></template>
    <div class="posts-skeleton">加载中...</div>
    <!--/$-->

    <template id="B:1"></template>
    <div class="comments-skeleton">加载中...</div>
    <!--/$-->
  </main>
  <footer><!-- Footer 内容 --></footer>

<!-- 注意:HTML 没有关闭,流仍然在传输 -->
<!-- 第二块:PostList 数据就绪(200ms 后) -->
<div hidden id="S:0">
  <article><h2>第一篇文章</h2><p>...</p></article>
  <article><h2>第二篇文章</h2><p>...</p></article>
</div>
<script>
  // React 的内联脚本:替换 fallback
  $RC("B:0", "S:0");
</script>
<!-- 第三块:RecentComments 数据就绪(800ms 后) -->
<div hidden id="S:1">
  <div class="comment">用户A:写得好</div>
  <div class="comment">用户B:学到了</div>
</div>
<script>
  $RC("B:1", "S:1");
</script>

  </body>
</html>
<!-- 流结束 -->

12.4.3 $RC 函数:流式替换的微型运行时

$RC(React Completed)是 React 注入到 HTML 流中的一个极小的内联脚本函数,它负责将 Suspense fallback 替换为实际内容:

// React 注入的流式替换运行时(简化自 react-dom/src/server/fizz-instruction-set)
function $RC(boundaryId: string, contentId: string) {
  const boundary = document.getElementById(boundaryId);
  const content = document.getElementById(contentId);

  if (boundary && content) {
    const parent = boundary.parentNode;
    // 收集 boundary 到 <!--/$--> 之间的 fallback 节点并移除
    let node = boundary.nextSibling;
    while (node && !(node.nodeType === 8 && node.data === '/$')) {
      const next = node.nextSibling;
      parent.removeChild(node);
      node = next;
    }
    // 将隐藏容器中的实际内容移到正确位置
    while (content.firstChild) {
      parent.insertBefore(content.firstChild, node);
    }
    boundary.remove();
    content.remove();
  }
}

这种设计的精妙之处在于:它不需要客户端 JavaScript bundle 已经加载$RC 是一个内联脚本,它直接操作 DOM,在 React 的 JavaScript 都还没有下载完成时就已经在工作了。这意味着用户可以在最短的时间内看到实际内容,而不是一直盯着 skeleton 等待 bundle 加载。

12.4.4 RSC 流与 SSR 流的协作

在完整的 RSC + SSR 场景中,实际上存在两层流:

数据流方向为:Flight Server(渲染 Server Components,生成 RSC Payload 流) -> Fizz Server(消费 RSC Payload,渲染 Client Components,生成 HTML 流) -> 浏览器(流式渲染 + Hydration)。

// 完整的 RSC + SSR 服务端流程(简化)
import { renderToPipeableStream as renderRSC } from 'react-server-dom-webpack/server';
import { renderToPipeableStream as renderHTML } from 'react-dom/server';
import { createFromReadableStream } from 'react-server-dom-webpack/client.edge';

async function handleRequest(req: Request): Promise<Response> {
  // 第一层:RSC 渲染,生成 Flight Payload 流
  const rscPayloadStream = renderRSC(
    <App url={req.url} />,
    bundlerConfig
  );

  // 将 RSC Payload 流转换为 React 元素树
  // 这一步会消费 Flight 流,解析其中的 Client Component 引用
  const [rscStream1, rscStream2] = rscPayloadStream.tee();

  // 第二层:将 RSC Payload 解析为可渲染的 React 树
  const ServerOutput = createFromReadableStream(rscStream1);

  // 第三层:SSR 渲染,将 React 树转为 HTML 流
  const { pipe } = renderHTML(
    <ServerOutput />,
    {
      bootstrapScripts: ['/client.js'],
      onShellReady() {
        // HTML Shell 就绪,开始响应
        // 同时将 RSC Payload 内联到 HTML 流中
        // (客户端 hydration 需要 RSC Payload)
      }
    }
  );

  return new Response(pipe(), {
    headers: { 'Content-Type': 'text/html' }
  });
}

深度洞察:两层流的设计看似复杂,实则解决了一个根本性的问题——RSC Payload 必须同时服务于两个消费者:SSR 渲染器需要它来生成初始 HTML,客户端 React 需要它来完成 hydration 和后续的客户端导航。tee() 操作将一个流分裂为两个,正是为了满足这个双重消费需求。

12.5 RSC 与 Next.js App Router 的深度集成

12.5.1 App Router 的请求处理管线

Next.js 的 App Router 是目前 RSC 最成熟的生产级实现。理解它的请求处理管线,是理解 RSC 实际运作方式的关键。

// Next.js App Router 的请求处理(简化示意)
// 基于 next/src/server/app-render/app-render.tsx

async function renderToHTMLOrFlight(
  req: IncomingMessage,
  res: ServerResponse,
  pathname: string,
  renderOpts: RenderOpts
): Promise<RenderResult> {

  // 判断是浏览器导航还是初始加载
  const isRSCRequest = req.headers['rsc'] !== undefined;
  // RSC 请求头表示这是客户端导航,只需要 Flight Payload
  // 非 RSC 请求是初始页面加载,需要完整 HTML

  // 构建组件树
  // App Router 中,文件系统 → 组件树的映射:
  // app/
  //   layout.tsx    → 根布局
  //   page.tsx      → 当前页面
  //   loading.tsx   → Suspense fallback
  //   error.tsx     → ErrorBoundary
  //   not-found.tsx → NotFound 边界

  const componentTree = (
    <Layout>           {/* app/layout.tsx - Server Component */}
      <Template>       {/* app/template.tsx (如果存在) */}
        <ErrorBoundary fallback={<Error />}>
          <Suspense fallback={<Loading />}>
            <Page params={params} searchParams={searchParams} />
          </Suspense>
        </ErrorBoundary>
      </Template>
    </Layout>
  );

  if (isRSCRequest) {
    // 客户端导航:只返回 RSC Flight Payload
    const flightPayload = renderToReadableStream(
      componentTree,
      clientReferenceManifest
    );
    return new RenderResult(flightPayload);
  } else {
    // 初始加载:返回完整 HTML(包含内联的 RSC Payload)
    return renderToInitialHTML(componentTree, clientReferenceManifest);
  }
}

12.5.2 客户端导航与 RSC 数据获取

在 App Router 中,客户端导航(点击 <Link>)不会导致全页面刷新,而是触发一次 RSC 请求:

// 客户端导航流程(简化示意)
// next/src/client/components/app-router.tsx

function navigate(href: string, options: NavigateOptions) {
  // 1. 向服务端发送 RSC 请求
  const flightResponse = fetch(href, {
    headers: {
      'RSC': '1',                    // 标记这是 RSC 请求
      'Next-Router-State-Tree': JSON.stringify(currentTree),
      'Next-Router-Prefetch': options.prefetch ? '1' : undefined,
    },
  });

  // 2. 流式解析 Flight Payload
  const serverResponse = createFromFetch(flightResponse, {
    callServer,  // Server Actions 的 RPC 通道
  });

  // 3. 使用 startTransition 应用新状态
  // 这确保了导航期间旧 UI 保持可交互
  startTransition(() => {
    // 将解析后的 React 树应用到 Router 状态
    dispatch({
      type: ACTION_NAVIGATE,
      payload: {
        url: new URL(href, location.origin),
        tree: serverResponse,
        isExternalUrl: false,
      },
    });
  });
}

12.5.3 缓存与重验证机制

Next.js 在 RSC 之上构建了多层缓存:

Next.js 在 RSC 之上构建了四层缓存:层级 1 - 客户端路由缓存(缓存已访问页面的 RSC Payload,动态页面 TTL 30s,静态页面 5min);层级 2 - 全路由缓存(服务端缓存静态渲染的路由,构建时生成);层级 3 - 数据缓存(缓存 fetch() 响应,支持 revalidaterevalidateTag);层级 4 - React 请求去重React.cache() 在同一次渲染中自动去重重复请求)。

// 缓存与重验证的实际使用

// 1. 基于时间的重验证
async function ProductList() {
  // 每 60 秒重新获取数据
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }
  }).then(r => r.json());

  return products.map(p => <ProductCard key={p.id} product={p} />);
}

// 2. 按需重验证(通过 Server Action)
"use server";
import { revalidateTag } from 'next/cache';

async function updateProduct(formData: FormData) {
  const id = formData.get('id') as string;
  await db.products.update({
    where: { id },
    data: { name: formData.get('name') as string }
  });

  // 使所有带 'products' 标签的缓存失效
  revalidateTag('products');
}

// 3. 请求级去重
import { cache } from 'react';

const getUser = cache(async (userId: string) => {
  const user = await db.users.findUnique({ where: { id: userId } });
  return user;
});

// 在同一次请求中,无论调用多少次 getUser('123'),
// 只会执行一次数据库查询
async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId);  // 查询
  return <div>{user.name}</div>;
}

async function UserAvatar({ userId }: { userId: string }) {
  const user = await getUser(userId);  // 命中缓存,不再查询
  return <img src={user.avatar} />;
}

12.5.4 部分预渲染(Partial Prerendering)

Next.js 14 引入的部分预渲染(PPR)将 RSC 的流式能力推向了极致——它在构建时生成静态 Shell,在请求时填充动态内容:

// 部分预渲染的工作原理
// app/product/[id]/page.tsx

export default async function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* 静态部分:构建时预渲染 */}
      <Header />
      <ProductLayout>
        {/* 动态部分:请求时流式填充 */}
        <Suspense fallback={<PriceSkeleton />}>
          <DynamicPrice productId={params.id} />
        </Suspense>

        <Suspense fallback={<StockSkeleton />}>
          <InventoryStatus productId={params.id} />
        </Suspense>

        {/* 静态部分 */}
        <ProductDescription productId={params.id} />
      </ProductLayout>
      <Footer />
    </div>
  );
}

// PPR 的效果:
// 1. 构建时:生成包含 Suspense fallback 的静态 HTML
// 2. 请求时:立即返回静态 Shell(从 CDN 边缘缓存)
// 3. 同时:在边缘节点开始动态渲染
// 4. 动态内容就绪后:流式注入到已发送的 HTML 中
//
// 结果:TTFB 接近静态站点,同时保持完全动态的能力

12.6 RSC 的性能模型与适用场景分析

12.6.1 RSC 的性能收益模型

RSC 的性能优势并非在所有场景下均匀分布。理解其收益模型需要分析多个维度:

RSC 的性能优势主要体现在三个维度:

首次加载性能(FCP/LCP):传统 CSR 需要等待 JS 下载、解析、执行后才能渲染;传统 SSR 虽然首屏快,但 Hydration 仍需要全量 JS。RSC + Streaming SSR 在发送 Shell 后即可让用户看到有意义的内容,Suspense 边界的内容逐步流式填充。

JavaScript Bundle 大小:传统应用打包全部组件和依赖,RSC 应用只打包 Client Component 及其依赖。在内容密集型应用中,bundle 体积可减少 40-70%。

客户端导航性能:传统 SPA 需要下载新路由的 JS chunk 并执行,RSC 只需获取 Flight Payload(数据+结构,而非代码+数据),通常体积远小于 JS chunk。

12.6.2 序列化开销与数据瀑布

RSC 也有其固有的性能陷阱,最常见的两个是序列化开销和数据瀑布:

// 陷阱一:过大的 props 导致 RSC Payload 膨胀
// ❌ 反模式:将大量数据作为 props 传递给 Client Component
async function DataPage() {
  const allRecords = await db.records.findMany(); // 10000 条记录

  return (
    // 这 10000 条记录会全部序列化到 RSC Payload 中
    // Payload 可能达到数 MB
    <InteractiveTable data={allRecords} />
  );
}

// ✅ 改进:在 Server Component 中完成数据处理
async function DataPage() {
  const allRecords = await db.records.findMany();

  // 在服务端进行聚合和筛选
  const summary = {
    total: allRecords.length,
    categories: groupBy(allRecords, 'category'),
    topItems: allRecords.slice(0, 50),
  };

  return <InteractiveTable summary={summary} />;
}
// 陷阱二:顺序数据获取(Request Waterfall)
// ❌ 串行获取:每个 await 都阻塞后续渲染
async function Dashboard() {
  const user = await getUser();           // 100ms
  const posts = await getPosts(user.id);  // 200ms(等 user 完成后才开始)
  const stats = await getStats(user.id);  // 150ms(等 posts 完成后才开始)
  // 总耗时:~450ms

  return <DashboardView user={user} posts={posts} stats={stats} />;
}

// ✅ 并行获取:利用 Promise.all 或组件级并行
async function Dashboard() {
  const user = await getUser();
  // 利用 Promise.all 并行获取独立数据
  const [posts, stats] = await Promise.all([
    getPosts(user.id),
    getStats(user.id),
  ]);
  // 总耗时:~300ms (100ms + max(200ms, 150ms))

  return <DashboardView user={user} posts={posts} stats={stats} />;
}

// ✅✅ 最优方案:利用 Suspense 实现组件级并行流式渲染
async function Dashboard() {
  const user = await getUser();

  return (
    <div>
      <UserInfo user={user} />
      {/* 这两个 Suspense 边界的数据获取完全并行 */}
      <Suspense fallback={<PostsSkeleton />}>
        <PostList userId={user.id} />  {/* 内部 await getPosts() */}
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel userId={user.id} /> {/* 内部 await getStats() */}
      </Suspense>
    </div>
  );
  // 用户立即看到 Shell + UserInfo
  // PostList 和 StatsPanel 各自独立加载,谁先就绪谁先显示
}

12.6.3 适用场景决策树

你应该用 RSC 吗?决策树:

你的页面有大量静态/只读内容吗?
├── 是 → RSC 非常适合
│   ├── 博客/文档类页面 → RSC 是最佳选择
│   ├── 电商产品页 → RSC + 少量 Client Component
│   └── 数据仪表盘 → RSC Shell + Client 交互组件

├── 否 → 大部分是高交互内容
│   ├── 实时协作工具 (如 Figma 类) → RSC 价值有限
│   ├── 游戏/画布类应用 → 不适合 RSC
│   └── 富文本编辑器 → Client Component 为主

└── 混合场景
    ├── 社交媒体 Feed → RSC 渲染帖子 + Client 交互
    ├── SaaS 管理后台 → RSC 布局/列表 + Client 表单/图表
    └── 需要离线能力 → 需要仔细设计 Server/Client 边界

12.6.4 RSC 与其他方案的对比

RSC 并非唯一的服务端渲染方案,理解它与其他方案的差异有助于做出正确选择:

RSC vs 传统 SSR + Hydration:传统 SSR 将全部组件代码发送到客户端进行全量 Hydration,而 RSC 只对 Client Component 进行 Hydration。在典型电商页面中,RSC 可以将 Hydration 耗时从 200-500ms 降低到 50-150ms。

RSC vs Islands Architecture (Astro):Islands 的理念是”静态优先,交互例外”——页面默认是静态 HTML,交互区域作为独立岛屿 hydrate。RSC 则是”服务端优先,交互下放”——所有组件默认在服务端运行,只有需要交互的才下放到客户端。RSC 的优势是支持 SPA 式的客户端导航和更细粒度的 Server/Client 组合。

RSC vs 纯客户端 SPA:SPA 不需要服务端、部署简单。但如果应用主要服务搜索引擎流量,或有大量静态内容,RSC 通过减少 bundle 体积和提供更快的首屏,可以带来显著的用户体验提升。

12.6.5 RSC 的底层约束与未来方向

RSC 当前仍然存在一些工程约束,了解这些约束对做出正确的架构决策至关重要:

// 约束 1: Server Component 不能使用 React 状态和副作用
// 这是设计如此,不是缺陷——Server Component 是纯函数

// 约束 2: Server Component 与 Client Component 之间的数据流是单向的
// Server → Client: 通过 props(必须可序列化)
// Client → Server: 通过 Server Actions("use server")

// 约束 3: Server Component 不支持 Class Component
// 只有函数组件(包括 async 函数组件)可以是 Server Component

// 约束 4: 第三方库兼容性
// 任何使用了 useState/useEffect/浏览器 API 的库
// 都需要在 "use client" 边界内使用
// 这可能需要创建包装组件:
"use client";
import { Chart } from 'third-party-chart-lib'; // 使用了 useRef + canvas
export { Chart }; // 重导出为 Client Component

// 未来方向:
// 1. Server Component 的部分重渲染(避免全组件树重新序列化)
// 2. 更细粒度的缓存失效机制
// 3. 跨请求的 Server Component 状态持久化
// 4. 与 Edge Runtime 的深度集成(将 RSC 渲染推到 CDN 边缘)

深度洞察:RSC 最深远的影响不在于技术层面,而在于它重新定义了”React 开发者”的职责边界。在 RSC 出现之前,React 开发者通常只关心浏览器端的渲染。RSC 之后,一个 React 组件可能直接执行数据库查询、读取文件系统、调用内部微服务——React 开发者需要同时具备前端和后端的思维模式。这不是一个工具链的变化,这是一个角色定义的变化。

12.6.6 React 19 中 RSC 实现的真实代码量:Flight + Fizz 双引擎

把本章讨论的”Flight 协议”和”流式 SSR” 对应到 React 19 仓库的实测——

packages/react-server/src/ 9009 行——包含两个独立运行的引擎——

文件角色
ReactFizzServer.js2523Fizz——§12.4 流式 SSR 引擎;renderToPipeableStream / renderToReadableStream 的核心
ReactFlightServer.js1688Flight——§12.3 RSC Wire Protocol 服务端序列化引擎
ReactFizzClassComponent.js695Fizz 处理 class 组件的特殊路径
ReactFizzHooks.js660Fizz 内部对 useState/useEffect 等 Hooks 的服务端模拟
ReactFlightReplyServer.js596Server Actions 的反向解析——客户端 form action POST 回来的多段表单数据反序列化
ReactFizzNewContext.js / ReactFlightNewContext.js300 / 269各自的 Context 实现(Fizz 用于 SSR、Flight 用于 RSC)
ReactServerStreamConfigNode.js / ...Edge.js245 / 184多 runtime stream config(Node 流 vs WHATWG ReadableStream)

packages/react-client/src/ReactFlightClient.js 1170 行——是 §12.3.3 讨论的”序列化引擎”在客户端的反序列化对应。

Flight 协议总量——服务端 1688 + 客户端 1170 + ReplyServer 596 = 3454 行——是 §12.3 这一节的全部物质基础。

Fizz(流式 SSR)总量——FizzServer 2523 + ClassComponent 695 + Hooks 660 = 3878 行——是 §12.4 的物质基础。

packages/react-server-dom-webpack/src/ 5986 行——但实测 4080 行实现 + 1906 行 tests——其中 ReactFlightDOMServerNode.js / ClientEdge.js / ClientBrowser.js 都只有 100~170 行——只是各 runtime 的薄适配器——真正的 Flight 协议代码全在上面 react-server / react-client 两个包里。

两条值得记住的物理事实——

  1. Flight 3454 行 vs Fizz 3878 行——几乎对等——RSC(“如何把组件树序列化到客户端”)和流式 SSR(“如何把 HTML 流式发到浏览器”)在工程量上对等——这印证了 §12.4.4 “RSC 流与 SSR 流的协作”的根本分工——它们是两个独立机器、组合工作不互相吞并;React 团队没有把 Flight 缩进 Fizz 里做特殊路径——是协议解耦的工程纪律
  2. 9 份 ReactFlightServerConfig.dom-*.js 配置文件(dom-edge-webpack / dom-node-esm / dom-bun / dom-legacy / dom-node-webpack / dom-node / dom-browser / custom 等)——每个 16~26 行——揭示 Flight 不是”一个实现”、是”一套协议 + 多个 runtime 适配”——这是为什么 §12.5 提到 “App Router 与 Edge Runtime 集成” 能成立——Edge 有专门的 ServerConfig、不是 Node 流硬塞进去的

串联到 §3.12.1 实测的 LangChain runnables/ 13730 行——React 一个 RSC 协议(Flight 3454)就用了 LangChain 的 runnables 全套 1/4 体量——印证 “协议定义本身比组合原语轻、但集成代价重” 的工程现实。

12.6.7 RSC 源码账本与”Flight 协议 vs Fizz 流”的代码密度对照

上一节的 §12.6.6 给出了 React 19 的静态快照,本节把该快照扩展为按关注点切片的账本——每一行都可以直接 curl 到 GitHub raw 源码重复验证(截至 main 2026-04)。

关注点文件所在包核心职责
Flight 服务端序列化ReactFlightServer.jsreact-server把 Server Component 树编码为 Flight 行文本
Flight 客户端反序列化ReactFlightClient.jsreact-client把 Flight 行文本还原为 React 元素树
Server Actions 反向通道ReactFlightReplyServer.jsreact-server解析客户端 POST 过来的多段表单
Server Actions 客户端发起ReactFlightReplyClient.jsreact-client在浏览器把 FormData / 参数编码回服务端
流式 SSR 引擎ReactFizzServer.jsreact-serverrenderToPipeableStream 的骨架
Fizz 内 Hooks 模拟ReactFizzHooks.jsreact-server服务端渲染期的 useState / useId / useMemo 投影
Flight Hooks(Server Hooks)ReactFlightHooks.jsreact-serverServer Component 里 cache / useId 的实现
多 runtime 适配(Webpack)server/, client/ 子目录react-server-dom-webpack绑定 Webpack 的 chunk manifest 与 ModuleReference
多 runtime 适配(Turbopack)同名骨架react-server-dom-turbopack复刻一份给 Turbopack
多 runtime 适配(ESM)同名骨架react-server-dom-esm复刻一份给标准 ESM(无打包器)

这里藏着一个容易被忽视的工程事实——RSC 不是一个实现、而是一个跨 3 个 dom- 适配包的协议族*。react-server-dom-webpackserver/client/ 目录下大量文件(ReactFlightDOMServerNode.js / ReactFlightDOMServerEdge.js / ReactFlightDOMClientBrowser.js 等)每个往往只有 100~200 行——它们只负责把 Flight 协议的字节流塞进各自 runtime 的流原语(Node 的 Readable vs WHATWG ReadableStream vs 浏览器 Response.body)。业务逻辑全部沉淀在 react-serverreact-client 两个中立包。

Flight vs Fizz 的代码密度对照

把 §12.3(Wire Protocol)和 §12.4(流式 SSR)对应回文件体量——

graph TB
    subgraph Flight["Flight 协议族(RSC 专属)"]
        FS["ReactFlightServer.js<br/>服务端序列化"]
        FC["ReactFlightClient.js<br/>客户端反序列化"]
        FR["ReactFlightReplyServer.js<br/>Server Actions 反向"]
        FH["ReactFlightHooks.js<br/>Server Hooks"]
    end
    subgraph Fizz["Fizz 引擎(流式 SSR)"]
        ZS["ReactFizzServer.js<br/>renderToPipeableStream"]
        ZH["ReactFizzHooks.js<br/>服务端 Hooks 投影"]
        ZC["ReactFizzClassComponent.js<br/>Class 组件兼容路径"]
    end
    subgraph Shared["共用"]
        SHARED["ReactSharedInternals<br/>调度器 / Lane 模型"]
    end
    Flight --> SHARED
    Fizz --> SHARED
    Flight -.Flight Payload.-> Fizz
    style Flight fill:#e3f2fd,stroke:#1565c0
    style Fizz fill:#fff3e0,stroke:#e65100

值得记住的三条事实——

  1. Flight 和 Fizz 是两台独立机器、通过流耦合而非函数耦合——服务端侧 Flight 吐出 Payload、Fizz 消费 Payload 吐出 HTML——它们从不互相 import 对方的核心函数;这是 §12.4.4 “两层流协作”的物质基础
  2. ReactFizzClassComponent.js 独立成文件——是历史债务隔离的典型手法——Class 组件在 Fizz 的处理路径与函数组件完全分叉、React 团队选择用单独文件封装分叉而非在主路径上加 if-else
  3. Server Actions 的反向通道(ReplyServer + ReplyClient)单独成文件——意味着 RSC 的 “服务端到客户端” 与 “客户端到服务端” 是两条独立协议、不共享序列化器——这也是为什么 "use server" 的序列化规则(只能标记 async 函数)与 "use client"(可以标记任何导出)完全不同

12.6.8 RSC vs CSR vs SSR vs SSG:六维对比矩阵

把”在哪里运行”这个单一轴拆成六个维度后,RSC 的定位才清晰可见。下表给出一手可量化的对照——

维度CSR(纯 SPA)SSG(静态生成)SSR(传统)SSR + Hydration(React 17)RSC + Streaming SSR(React 18+)
首字节到达(TTFB)快(CDN HTML 骨架)最快(纯 CDN)慢(等服务端全量渲染)中(等 shell 就绪)(shell 立即流出)
首屏可见(FCP)慢(等 JS 下载执行)最快最快(Suspense 分块注入)
可交互(TTI)中(需 hydrate)慢(等 JS + hydrate)分桶:Shell 即可交互、Suspense 岛屿逐个可交互
JS Bundle 体积最大(全量)中(仅交互层)最小(仅 Client Component + 其依赖)
数据获取模型客户端 fetch(瀑布)构建期固化服务端 fetch(一次性)服务端 fetch组件级 async / Suspense 并行
SEO差(需额外处理)
动态性完全动态完全静态(需重新构建)完全动态完全动态完全动态(+ PPR 支持混合)
对服务端的依赖无(仅 CDN)无(构建期)(每次请求渲染)(且需 RSC runtime)
组件模型函数/Class函数/Class函数/Class函数/ClassServer Component(async)+ Client Component
典型框架Vite + ReactAstro / Next output: exportExpress + renderToStringNext.js Pages RouterNext.js App Router / Remix RSC

表格最值得注意的两列交叉——

  • “SSG 的 TTFB 最快” vs “RSC 的 TTI 最优”——揭示了 Next.js 14 部分预渲染(PPR) 的存在动机——把”SSG 的 shell”和”RSC 的流式动态区”组合、同时拿到两列冠军;§12.5.4 讨论的 PPR 不是工程噱头、是这张矩阵上的一个帕累托跃迁
  • “RSC 的 JS Bundle 最小” vs “RSC 对服务端依赖最强”——揭示 RSC 不是 CSR 的”升级”、是另一条技术路线——选 RSC 等于选”永远需要一台能跑 Node/Edge 的服务端”、这对纯 CDN 部署(GitHub Pages / Cloudflare Pages 静态模式)是一条不可跨越的架构门槛

12.6.9 跨章串联:Fiber · Flight · 合成事件的三点一线

RSC 不是孤岛——它的协议设计、数据流、事件接入点都必须和前几章描述的 Fiber 架构 / 合成事件系统精确对齐。本节给出三个跨章锚点——

锚点一:RSC 元素树与 Fiber 树的对应(对应第 3 章 Fiber)——Flight Server 序列化时输出的 ["$", type, key, props] 结构——在客户端 ReactFlightClient.js 反序列化之后——会被 createElement 重新包装成标准 React Element——再进入 Fiber 协调器走和 CSR 完全一致的 reconcile 路径。换句话说——RSC 没有重新实现一套 Fiber——它只是把”Element 的来源”从”浏览器端执行 JSX”替换为”从网络流反序列化”。第 3 章描述的 FiberRoot / WorkInProgress / Lane 调度模型在 RSC 路径下一字不改地复用

锚点二:Client Component 的 hydration 与合成事件挂载(对应第 14 章事件系统)——当 ReactFlightClient.js 遇到 I 行(模块引用)时——会触发对应 chunk 的加载——chunk 加载完成后——React 在该 Client Component 的根节点调用 hydrateRoot——hydrateRoot 的第一件事就是在 root 容器上执行 §14.1 描述的 listenToAllSupportedEvents。这意味着——RSC 的交互能力最终落到第 14 章描述的”root 容器事件委托”之上——Server Component 生成的 DOM 节点本身不绑定任何事件监听器、所有 onClick/onChange 都是通过 root 委托在 Client Component 的 hydration 后接管的。RSC 和合成事件在架构上是层叠关系——Flight 负责数据、合成事件负责交互——没有谁包含谁。

锚点三:Suspense 边界的双重角色(对应第 4 章 Scheduler 与本章 §12.4)——Suspense 在第 4 章里是 CPU-bound 任务的时间切片边界useTransition 标记过渡)——在本章 §12.4 里是 I/O-bound 任务的流式分块边界Suspense fallback 包裹 async 组件)。看似两个用途——底层用的是同一套 Fiber Lane 机制——React 团队没有为流式 SSR 发明新的暂停原语、而是把现有的 Suspense throw/catch 模型升级为可跨进程工作(Flight Server 可以暂停一个组件、等数据就绪再继续序列化、客户端看到的只是一行”暂挂行 ID”)。这是为什么 Suspense 在 18 之后”突然变得强大”——不是它自己变强了、而是 Flight 协议给它提供了跨越服务端/客户端边界的传输层。

深度洞察:三个锚点连起来——得出一条架构耐受性规律——一个健康的大型框架、核心抽象应该能跨代保持不变、只在边缘做替换。Fiber(2017)、Suspense(2018)、Lane(2020)都在 RSC(2023+)架构下未经修改地继续服役——React 团队在引入 RSC 时做的是加层、不是换层——这是它能在不破坏生态的前提下完成”全栈组件”转身的工程前提。对比很多框架选择”大版本推倒重写”——React 的这种”向下兼容式激进创新”在前端史上是罕见的。

12.7 本章小结

React Server Components 代表着 React 架构的一次根本性演进——从”客户端渲染框架”走向”全栈组件框架”。这次演进不是为了追赶时髦,而是对”组件应该在哪里运行”这个长期被忽视的问题给出了一个优雅的答案。

关键要点:

  1. RSC 的核心价值是”零 bundle size”组件:Server Component 的代码和依赖永远不会传输到客户端,这在内容密集型应用中可以减少 40-70% 的 JavaScript 体积
  2. "use client" 是编译器边界,不是运行时 API:它在模块依赖图中创建了 Server/Client 的分割线,最佳实践是将这条线推到尽可能靠近叶子节点的位置
  3. Flight 协议是 RSC 的传输层:它使用行文本格式支持流式解析,每行数据都可以被独立处理,使得客户端可以在服务端还在渲染时就开始处理数据
  4. 流式 SSR 通过 Suspense 边界实现增量交付:Shell 立即发送,Suspense 包裹的内容在数据就绪后通过内联脚本注入
  5. RSC 不是银弹:高交互应用、实时协作工具、离线优先应用中 RSC 的价值有限,正确的架构决策需要理解其性能模型的适用范围

物理事实:React 19 RSC 实现 = react-server/ 9009 行(Flight 3454 + Fizz 3878 + Context 等)+ react-client ReactFlightClient.js 1170 + react-server-dom-webpack/ 4080 行(薄 runtime 适配器);9 份 ReactFlightServerConfig.dom-*.js 配置揭示 Flight 是”协议 + 多 runtime”。


思考题

  1. 为什么 Flight 协议选择行文本格式而不是更紧凑的二进制格式(如 Protocol Buffers 或 MessagePack)? 从流式解析的复杂度、HTTP/2 帧层的压缩、调试可观测性三个角度分析这个设计决策的合理性。

  2. 考虑以下场景:一个 Server Component 需要根据用户的浏览器语言偏好(Accept-Language 请求头)渲染不同的内容。但 Server Component 没有对 request 对象的直接访问。请设计一种方案,使得 Server Component 能够获取请求头信息,同时不破坏组件的可缓存性。分析 Next.js 的 headers() API 是如何实现这一点的。

  3. RSC 的 Selective Hydration 机制允许 React 优先 hydrate 用户正在交互的 Suspense 边界。 假设一个页面有 3 个 Suspense 边界 A、B、C 依次加载完成,但用户在 B 加载完成之前点击了 C 区域。请分析 React 的 hydration 优先级调度策略:C 是否会”插队”优先于 B 完成 hydration?如果 C 内部还有嵌套的 Suspense 边界会怎样?

  4. RSC 的数据获取模式(async Server Component)与 Relay 的 “render-as-you-fetch” 模式有什么异同? 从数据瀑布问题的角度分析,RSC 是否完全解决了瀑布问题?如果没有,它提供了哪些工具来缓解这个问题?