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 SSR:
getServerSideProps每次请求拉数据,客户端 React 18.3 水合。 - Next.js App Router RSC:服务端组件直连 SQLite,客户端组件仅承载交互。
- CRA + React CSR:Create React App 5,react-router-dom 6.26,纯浏览器渲染。
2.2 数据对比
| 指标 | PHP 传统 SSR | Pages Router SSR | App Router RSC | CRA CSR |
|---|---|---|---|---|
| TTFB(完整页面) | 214ms | 198ms | 186ms | 88ms |
| 首屏 HTML(gzip) | 18.4KB | 14.2KB | 7.2KB(流式首块 96ms 到达) | 1.1KB |
| JS 传输量(gzip) | 46.8KB | 128KB | 43.8KB | 289KB |
| FCP | 612ms | 548ms | 418ms | 1.9s |
| LCP | 1.36s | 1.28s | 1.02s | 3.41s |
| TTI | 2.87s | 2.54s | 1.13s | 3.86s |
| TBT | 180ms | 210ms | 40ms | 520ms |
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 传统 SSR | Pages Router SSR | App Router RSC | CRA CSR |
|---|---|---|---|---|
| JS 总量(gzip) | 46.8KB | 128KB | 43.8KB | 289KB |
| FCP | 612ms | 548ms | 418ms | 1.9s |
| LCP | 1.36s | 1.28s | 1.02s | 3.41s |
| TTI | 2.87s | 2.54s | 1.13s | 3.86s |
| TBT | 180ms | 210ms | 40ms | 520ms |
| Performance 分数 | 86 | 88 | 98 | 64 |
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.ts,bcryptjs 加密逻辑只保留在服务端组件里。
七、结论
RSC 的 Flight 协议不是黑魔法。它就是一套带类型标记的行式序列化格式,服务端产生数据流,客户端按行消费。理解这套格式后,缓存、调试、性能优化都有迹可循。
RSC 也不是银弹。交互密集、数据实时性强的应用,CSR 的灵活性和简单性更好。如果你的页面以展示为主,且你有能力控制数据变更频率,RSC 的收益是实打实的。
动手之前,先用 curl 抓一次你项目的 RSC payload。看到那些 $L 和 $S 标记时,你对架构的理解会上一个台阶。