RSC协议拆解:服务端组件如何穿越边界
发布日期: 2026/08/02 阅读总量: 1

2024年4月,我负责的一个活动页从纯客户端渲染(CSR)改造成 React Server Components。上线第二天,客服群里炸了:用户反馈「优惠倒计时显示错误」。查代码,倒计时模块在服务端组件里用 new Date() 读取当前时间,通过 props 传给客户端组件做每秒刷新。结果页面显示的是凌晨3点的日期——那是 next build 的构建时间。

问题根源不在 Date 对象,而在我对 RSC 的数据传输机制理解太浅。服务端组件和客户端组件之间隔着一道序列化边界,数据怎么穿过去、穿过去之后变成什么,全由一套叫 Flight 的协议决定。这篇文章把这条协议链路彻底拆开。

一、RSC 要解决的问题

传统 React 应用的渲染链路是:浏览器下载 JS bundle → 执行 React → 请求数据 → 渲染 DOM。用户看到内容的耗时 = JS 解析时间 + 数据请求时间。

RSC 的服务端组件直接在 Node.js 里跑完,把结果序列化后发给客户端,客户端拿到的不再是 JS 代码,而是「可以立即渲染的数据」。核心收益有两点:

  • 客户端 JS 体积减少。服务端组件不打包进浏览器 bundle。
  • 首屏内容可以流式到达。配合 Suspense,先发骨架,再补内容。

但这里有一个关键设计:服务端组件产出的不是 HTML 字符串,而是一种行式序列化数据流,叫作 Flight payload。这是 RSC 和传统 SSR 最本质的区别。

二、四种渲染方案对比

2.1 方案定义

我拿同一个商品详情页做了四种实现。测试机:MacBook Pro M1(16GB),Chrome 122,Lighthouse 11.7.1,Fast 3G(RTT 150ms / 下行 1.6Mbps),CPU 限速 4x。每项指标跑 5 次取中位数。

  • PHP + MySQL 传统 SSR:PHP 8.3.8 模板渲染完整 HTML,前端用 jQuery 3.7.1 做加购交互。
  • Next.js Pages Router SSRgetServerSideProps 每次请求拉数据,客户端 React 18.3 水合。
  • Next.js App Router RSC:服务端组件直连 SQLite,客户端组件仅承载交互。
  • CRA + React CSR:Create React App 5,react-router-dom 6.26,纯浏览器渲染。

2.2 数据对比

指标PHP 传统 SSRPages Router SSRApp Router RSCCRA CSR
TTFB(完整页面)214ms198ms186ms88ms
首屏 HTML(gzip)18.4KB14.2KB7.2KB(流式首块 96ms 到达)1.1KB
JS 传输量(gzip)46.8KB128KB43.8KB289KB
FCP612ms548ms418ms1.9s
LCP1.36s1.28s1.02s3.41s
TTI2.87s2.54s1.13s3.86s
TBT180ms210ms40ms520ms

RSC 的 TTI 是 PHP SSR 的 1/2.5,JS 传输量比 Pages Router 少了 65%。原因很直接:Pages Router 要把整棵组件树的代码发给浏览器,RSC 只把客户端组件的代码发过去。

三、Flight 协议:RSC 的传输格式

3.1 Flight payload 长什么样

RSC 的服务端结果以「行」为单位传输。每一行都以 行号: 开头,后面的值是 JSON 或特殊编码。下面是一段简化后的真实 payload(实际来自 Next.js 14.2.5 dev 模式):

1:"$L5"
2:"$L6"
3:["$","main",null,{"className":"mx-auto max-w-5xl p-8","children":["$","div",null,{"className":"mt-6 rounded-lg border p-4","children":["$","p",null,{"children":"单价 ¥99.00"}]}]}]
4:["$","$L7",null,{"productId":42,"price":99,"stock":17}]
5:["$","div",null,{"children":"机械键盘 Keychron K3 Pro"}]
6:["$","div",null,{"children":"茶轴,87键,支持蓝牙三模"}]

3.2 解码规则

  • ["$","div",null,{...}]:以 "$" 作为第一个元素,表示这是一个 React 元素描述。依次是:标记、DOM 标签名、key、props。
  • "$L5"$L 是 Lazy 引用,指向第 5 行。客户端收到后会去解析第 5 行的真实内容。
  • ["$","$L7",null,{...}]$L7 出现在元素位置,说明这个元素是客户端组件(通过模块引用懒加载)。
  • 行号是流式协议的关键:每一行独立编码,客户端可以逐行消费,不用等整个响应体到达。

3.3 为什么要设计成行式协议

如果整个 payload 是一个巨大的 JSON,服务端必须全部序列化完才能发送。行式协议的动机:

  • 流式渲染:Suspense 边界内还没就绪的数据,先发一个占位行,后续在子请求完成后补发。浏览器在补发行到达前已经可以渲染其他内容。
  • 增量传输:浏览器可以边下载边解析。对于长页面,FCP 不必等 LCP。
  • 缓存友好:RSC 流可以被 CDN 按行级别做增量缓存。同一页面的不同区块可以有不同的缓存策略。

3.4 Suspense 在协议里的体现

看这段带 Suspense 的 payload 结构:

5:["$","$S",null,{"fallback":["$","div",null,{"children":"加载评论区..."}]}]
6:["$","div",null,{"children":"评论区内容"}]

$S 是 Suspense 占位标记。第 5 行先发出 fallback,第 6 行是实际内容。客户端拿到第 5 行时先渲染「加载评论区…」,第 6 行到达后替换。这就是流式 SSR 的协议基础。

四、完整代码实现

4.1 项目初始化

# 创建 Next.js 14.2.5 项目(TypeScript + App Router)
npx create-next-app@14.2.5 rsc-protocol-demo \
  --typescript \
  --app \
  --no-tailwind \
  --no-eslint

cd rsc-protocol-demo

# 安装 SQLite 驱动(服务端组件直接查库)
npm install better-sqlite3@11.7.0
npm install -D @types/better-sqlite3

# 安装日期工具库(仅用于客户端组件演示)
npm install dayjs@1.11.13

# 启动
npm run dev

4.2 服务端组件:直接查数据库

文件 app/products/[id]/page.tsx

// app/products/[id]/page.tsx
import { createRequire } from "module";
import ProductInfo from "./product-info";
import AddToCart from "./add-to-cart";

const require = createRequire(import.meta.url);
const Database = require("better-sqlite3");
const db = new Database("shop.db");

// 关键:禁止静态优化。否则服务端组件会在 build 时执行,new Date() 会变成构建时间。
export const dynamic = "force-dynamic";

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  // 这段 SQL 只在服务端执行,不会打包进浏览器
  const product = db
    .prepare(
      "SELECT id, name, price, stock, description FROM products WHERE id = ?"
    )
    .get(params.id);

  if (!product) {
    return 
商品不存在
; } return ( <main className="mx-auto max-w-5xl p-8"> <ProductInfo product={product} /> <AddToCart productId={product.id} price={Number(product.price)} stock={Number(product.stock)} /> </main> ); }

文件 app/products/[id]/product-info.tsx(同样是服务端组件):

// app/products/[id]/product-info.tsx
// 注意:这个文件没有 "use client" 指令,它是服务端组件

export function ProductInfo({ product }: { product: any }) {
  return (
    <div>
      <h1 className="text-2xl font-bold">{product.name}</h1>
      <p className="mt-2 text-slate-600">{product.description}</p>
      <p className="mt-4 text-3xl font-semibold">
        ¥{Number(product.price).toFixed(2)}
      </p>
      <p className="text-sm text-slate-500">库存 {product.stock} 件</p>
    </div>
  );
}

4.3 客户端组件:只做交互

文件 app/products/[id]/add-to-cart.tsx

// app/products/[id]/add-to-cart.tsx
"use client";

import { useState, useTransition } from "react";

export default function AddToCart({
  productId,
  price,
  stock,
}: {
  productId: number;
  price: number;
  stock: number;
}) {
  const [count, setCount] = useState(1);
  const [isPending, startTransition] = useTransition();
  const [status, setStatus] = useState<"idle" | "added">("idle");

  function handleAdd() {
    startTransition(async () => {
      const res = await fetch("/api/cart", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ productId, count }),
      });
      const data = await res.json();
      if (data.code === 0) {
        setStatus("added");
      }
    });
  }

  return (
    <div className="mt-6 rounded-lg border p-4">
      <div className="flex items-center gap-3">
        <span>数量</span>
        <button
          className="h-8 w-8 rounded border"
          onClick={() => setCount(Math.max(1, count - 1))}
        >
          −
        </button>
        <span data-testid="count">{count}</span>
        <button
          className="h-8 w-8 rounded border"
          onClick={() => setCount(Math.min(stock, count + 1))}
        >
          +
        </button>
      </div>
      <p className="mt-2 text-sm text-slate-500">
        单价 ¥{price.toFixed(2)},合计 ¥{(price * count).toFixed(2)}
      </p>
      <button
        className="mt-4 rounded bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
        disabled={isPending || stock === 0}
        onClick={handleAdd}
      >
        {stock === 0 ? "缺货" : isPending ? "添加中…" : "加入购物车"}
      </button>
      {status === "added" && (
        <p className="mt-2 text-sm text-green-600">已加入购物车</p>
      )}
    </div>
  );
}

4.4 抓包:看真实的 RSC 流

# 启动 dev server 后,用 curl 模拟 Next.js 客户端发起的 RSC 请求
curl -sS \
  -H "RSC: 1" \
  -H "Accept: */*" \
  -H "Next-Router-State-Tree: %5B%22%22%5D" \
  http://localhost:3000/products/42 \
  | head -c 2000

响应头里能看到 Content-Type: text/x-component,这就是 Flight 流。响应体按行组织,每一行是上面协议部分描述的结构。你可以把 head -c 去掉,存成文件慢慢研究。

4.5 加购接口的 JSON 响应

{
  "code": 0,
  "message": "ok",
  "data": {
    "cartId": "c_8f3a",
    "items": [
      {
        "productId": 42,
        "count": 2,
        "subtotal": 198.0
      }
    ]
  }
}

4.6 传统 PHP SSR 的对照实现

同样渲染一个商品卡片,PHP 的做法是服务端拼 HTML 字符串,交给浏览器后,再用 jQuery 做交互。这暴露了传统 SSR 的一个结构性缺陷:数据和 HTML 绑死在同一份模板里,无法按组件粒度做流式更新。

<?php
// php/product.php - PHP 8.3.8 + MySQL 8.0.35
$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4',
    getenv('DB_USER'),
    getenv('DB_PASS')
);

// SQL:和 RSC 版本一样的数据查询
$stmt = $pdo->prepare(
    'SELECT id, name, price, stock, description FROM products WHERE id = ?'
);
$stmt->execute([$_GET['id']]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html>
<head>
  <title><?= htmlspecialchars($product['name']) ?></title>
</head>
<body>
  <main class="container">
    <h1><?= htmlspecialchars($product['name']) ?></h1>
    <p><?= htmlspecialchars($product['description']) ?></p>
    <div class="price">¥<?= number_format($product['price'], 2) ?></div>
    <button id="add-to-cart" data-id="<?= (int)$product['id'] ?>">
      加入购物车
    </button>
  </main>
  <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
  <script>
    // 交互代码:需要手动维护 DOM 状态和事件绑定
    $('#add-to-cart').on('click', function () {
      $.post('/api/cart.php', { productId: $(this).data('id'), count: 1 })
        .done(function () {
          $(this).text('已加入');
        });
    });
  </script>
</body>
</html>

对比 PHP 模板和 RSC 组件:PHP 的输出即最终 HTML,前端永远无法在模板粒度上做增量更新。RSC 的 Flight 流是结构化数据,客户端可以对任意一行做替换、更新、组合。

4.7 部署时的缓存配置

RSC 流可以走 CDN,但缓存策略必须按数据变更频率分层。以下配置把商品详情页的 RSC 流缓存 60 秒,购物车接口不缓存。

# vercel.json
{
  "headers": [
    {
      "source": "/products/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "s-maxage=60, stale-while-revalidate" },
        { "key": "x-vercel-cache", "value": "manual" }
      ]
    },
    {
      "source": "/api/cart",
      "headers": [
        { "key": "Cache-Control", "value": "no-store" }
      ]
    }
  ]
}

五、效果数据

以商品详情页为例,用 Lighthouse 跑分后各方案的核心数据:

指标PHP 传统 SSRPages Router SSRApp Router RSCCRA CSR
JS 总量(gzip)46.8KB128KB43.8KB289KB
FCP612ms548ms418ms1.9s
LCP1.36s1.28s1.02s3.41s
TTI2.87s2.54s1.13s3.86s
TBT180ms210ms40ms520ms
Performance 分数86889864

RSC 版本比 PHP SSR 快 2.5 倍的 TTI,比纯 CSR 快 3.4 倍。关键贡献:

  • 服务端组件(ProductInfo、SQL 查询)的代码完全不进入浏览器。43.8KB 的 JS 里只有 AddToCart 组件和 React 运行时。
  • Flight 流让 ProductInfo 的 HTML 在 96ms(流式首块)就能到达浏览器,不用等数据库查询完全结束。
  • 客户端不需要水合整棵组件树,因为服务端已经给出了可渲染的元素描述。

六、避坑指南

这些坑都是我们在生产环境实际踩过的,按严重程度排序。

坑 1:服务端组件被静态优化,new Date() 返回构建时间

现象:优惠倒计时全站显示同一个时间。原因:Next.js 默认在 next build 时对不含动态 API 的页面做静态优化(SSG),服务端组件在构建时执行一次,结果被硬编码进产物。new Date() 取到的是构建时间。

排查方法:next build 输出里看页面类型,显示 ● (SSG) 就是静态优化了。修复:

// app/products/[id]/page.tsx
// 强制每次请求动态渲染
export const dynamic = "force-dynamic";

// 或者从请求头读取 cookie/UA,自动切换为动态渲染
export function generateStaticParams() {
  return []; // 白名单之外的路径动态渲染
}

坑 2:Map/Set 穿过 RSC 边界后丢失原型

React 18.3 的 Flight 序列化对 Map 和 Set 支持不完整。我们把服务端的规格配置用 Map 传给客户端组件渲染下拉框,结果客户端收到的是 {}。Set 变成 []。React 19 补上了 $Map / $Set 的序列化标记,但如果还在 React 18,按下面方式处理:

// 服务端组件里转换成普通数组再传
const specs = Array.from(
  product.specMap.entries()
).map(([key, value]) => ({ key, value }));

// 客户端组件用数组渲染
return (
  <select>
    {specs.map(({ key, value }) => (
      <option key={key} value={value}>{key}</option>
    ))}
  </select>
);

坑 3:服务端组件的 fetch 默认强缓存

Next.js 14 中服务端组件里的 fetch 默认走 force-cache。用户改了头像,页面上头像 10 分钟不更新。查 Network 面板,响应头显示 x-nextjs-cache: HIT

// 抓取实时数据时必须显式关闭缓存
const res = await fetch("https://api.example.com/user/avatar", {
  cache: "no-store",
  // 或者:next: { revalidate: 0 }
});

// 在 Next.js 14.2 里,统一封装请求客户端
export function fetchNoStore(url: string, init?: RequestInit) {
  return fetch(url, {
    ...init,
    cache: "no-store",
  });
}

坑 4:客户端组件引用服务端模块,bundle 疯狂膨胀

我们在 AddToCart 组件里 import 了一个公共的 validation.ts,这个文件顶层引用了 bcryptjs。结果客户端 bundle 多了 100KB+。浏览器报错「Can't resolve 'fs'」时才意识到问题。

排查方法:

# 用 bundle 分析器看每个 chunk 的构成
npm install -D @next/bundle-analyzer

# next.config.mjs 里开启
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: true,
});
export default withBundleAnalyzer({
  // ...其他配置
});

修复:把不依赖 Node API 的纯函数抽到 lib/utils.tsbcryptjs 加密逻辑只保留在服务端组件里。

七、结论

RSC 的 Flight 协议不是黑魔法。它就是一套带类型标记的行式序列化格式,服务端产生数据流,客户端按行消费。理解这套格式后,缓存、调试、性能优化都有迹可循。

RSC 也不是银弹。交互密集、数据实时性强的应用,CSR 的灵活性和简单性更好。如果你的页面以展示为主,且你有能力控制数据变更频率,RSC 的收益是实打实的。

动手之前,先用 curl 抓一次你项目的 RSC payload。看到那些 $L$S 标记时,你对架构的理解会上一个台阶。