一、一个真实场景:CSR 改 SSR 后,我的列表页还是慢
去年我负责一个电商产品列表页,最初用 Create React App 纯客户端渲染。首屏白屏 3 秒以上,SEO 完全拿不到内容。老板要求整改。
我第一反应是上 SSR:Next.js Pages Router + getServerSideProps。改完后,白屏消失,Lighthouse 分数从 35 飙到 75。但线上监控显示:首次交互时间(TTI)仍在 4.5 秒左右,用户点击“加入购物车”卡顿严重。进一步分析发现,即使数据在服务端渲染成了 HTML,但整个 React 应用仍需要发送约 400KB 的 JavaScript 并完成 hydrate 才能交互。Hydrate 过程阻塞了主线程,导致 TTI 很长。
直到我尝试了 React Server Components(RSC)—— 这才真正解决了问题。
二、方案对比:CSR、SSR、RSC 的痛与痒
我用同一个商品列表页,实现了三种渲染模式,控制单一变量:
- CSR(客户端渲染):React 18 + 客户端 fetch,无服务端参与。
- SSR(服务端渲染):Next.js Pages Router + getServerSideProps,完整页面服务端生成 HTML。
- RSC(服务端组件):Next.js 14 App Router,列表组件标记为服务端组件,交互部分单独提取为客户端组件。
所用环境:Next.js 14.2.5,React 18.3.1,Node 20.12.0,部署在阿里云 ECS 2 核 4G。
| 指标 | CSR | SSR | RSC |
|---|---|---|---|
| LCP (ms) | 2800 | 1600 | 920 |
| TTI (ms) | 5200 | 4500 | 1850 |
| FCP (ms) | 2700 | 1100 | 850 |
| Total Blocking Time (ms) | 1800 | 2100 | 320 |
| JavaScript 包体积 (KB) | 420 | 380 | 108 |
| 服务端 HTML 体积 (KB) | 4(空壳) | 256(完整 HTML) | 62(RSC Payload + 部分 HTML) |
测试工具:Lighthouse 11.4.0,模拟 Fast 3G 网络,降速 4x CPU。每个方案跑 5 次取中位数。
关键发现:RSC 的 TTI 比 SSR 缩短 59%,JavaScript 包体积减少 72%。原因在于:RSC 只把需要交互的客户端组件(如“加入购物车”按钮)发送到浏览器,占 95% 数据渲染逻辑的列表组件完全在服务端执行,不产生任何 JS chunk。
三、RSC 原理深度解析
3.1 组件树的“分裂”
RSC 引入了一个核心规则:组件要么是服务端组件(默认),要么是客户端组件(需显式添加 "use client")。服务端组件只在 Node.js 环境执行,可以访问数据库、文件系统、API 密钥,生成的结果是一个称为 RSC Payload 的纯 JSON 结构(包含组件树描述和实际 HTML 片段)。客户端组件则在浏览器中运行,支持 useState、useEffect、事件处理等交互逻辑。
3.2 渲染流程
- 生成 RSC Payload:服务端从根组件开始递归渲染,遇到客户端组件时,将其标记为引用并转到一个特殊 chunk。
- 流式传输:RSC Payload 不是一次性完整发送,而是通过 Suspense 边界分块流式发送,浏览器可以逐步展示内容。
- 客户端组装:浏览器收到 RSC Payload 后,React 用其构建虚拟 DOM,同时加载客户端组件的 JS chunk 并执行 hydrate,仅对所需的交互部分激活。
3.3 关键区别:SSR vs RSC
- SSR:服务端生成完整 HTML + JSON 数据,然后客户端 hydrate 整个应用(包括非交互部分也要 hydrate)。
- RSC:服务端组件不生成 JS,客户端只 hydrate 标记为 "use client" 的部分。非交互部分数据通过 RSC Payload 传输,无需执行任何 JS。
四、完整代码实现:一个商品列表页的 RSC 改造
下面我用 Next.js 14 App Router 演示。先建一个空项目:
npx create-next-app@14.2.5 rsc-demo --typescript --tailwind
cd rsc-demo
npm install prisma @prisma/client
npx prisma init
数据库我用 SQLite 方便演示(schema.prisma):
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Product {
id Int @id @default(autoincrement())
name String
price Float
imageUrl String
category String
createdAt DateTime @default(now())
}
初始化数据库并插入 50 条测试数据(略,可用脚本)。
核心代码 1:服务端组件 – 列表页 `/app/products/page.tsx`
import { prisma } from '@/lib/prisma';
import ProductCard from './ProductCard';
export default async function ProductListPage() {
// 直接数据库查询(只在服务端执行)
const products = await prisma.product.findMany({
take: 50,
orderBy: { createdAt: 'desc' },
});
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
注意:这是一个 async 函数,直接在服务端执行数据库查询。没有 useEffect,没有 fetch。代码不会打包到浏览器中。
核心代码 2:客户端组件 – 单个商品卡片 `/app/products/ProductCard.tsx`
'use client'; // 必须第一行
import { useState } from 'react';
interface Product {
id: number;
name: string;
price: number;
imageUrl: string;
}
export default function ProductCard({ product }: { product: Product }) {
const [added, setAdded] = useState(false);
const handleAddToCart = () => {
setAdded(true);
// 调用购物车 API
fetch('/api/cart/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id }),
});
setTimeout(() => setAdded(false), 2000);
};
return (
<div className="border rounded-lg p-4 shadow">
<img src={product.imageUrl} alt={product.name} className="w-full h-40 object-cover" />
<h3 className="text-lg font-bold mt-2">{product.name}</h3>
<p className="text-gray-600">¥{product.price}</p>
<button
onClick={handleAddToCart}
className={`mt-2 px-4 py-2 rounded ${added ? 'bg-green-500' : 'bg-blue-500'} text-white`}
>
{added ? '已加入' : '加入购物车'}
</button>
</div>
);
}
这里 `'use client'` 告诉打包工具:这是一个客户端组件,需要在浏览器中加载其 JS。注意:ProductCard 虽然被服务端组件引用,但它自身不会在服务端渲染 HTML?不——它仍然会在服务端生成 HTML 作为初始内容,但同时也会发送其 JavaScript chunk 用于 hydrate。这就是 RSC 的关键:只有客户端组件的 JS 会被发送。
核心代码 3:布局文件混合使用
// app/products/layout.tsx
export default function ProductsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="max-w-7xl mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">商品列表</h1>
{children}
</section>
);
}
布局文件默认也是服务端组件,可以包含静态内容,不会增加 JS 体积。
核心代码 4:配置文件 next.config.ts
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 启用 RSC(Next.js 14 默认启用)
// experimental: { serverComponents: true } // 不再需要
};
export default nextConfig;
核心代码 5:API 路由(用于客户端组件发起的购物车请求)
// app/api/cart/add/route.ts
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { productId } = await request.json();
// 实际写入数据库或缓存
console.log(`Adding product ${productId} to cart`);
return NextResponse.json({ success: true });
}
完成了。现在 `npm run dev` 访问 `/products`。打开 DevTools 的 Network 面板,可以看到只加载了 `ProductCard.js`(一个很小的 chunk),而列表的 HTML 是通过 RSC Payload 流式传输的。
五、效果数据:真实压测对比
除了 Lighthouse,我还用 Puppeteer 脚本在无头浏览器下采集指标:
# 启动生产构建
npm run build && npm run start
# 用 Puppeteer 脚本(简化)
node benchmark.js
// benchmark.js 截取核心
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setCacheEnabled(false);
const start = Date.now();
await page.goto('http://localhost:3000/products', { waitUntil: 'networkidle0' });
const tti = Date.now() - start;
const metrics = await page.metrics();
console.log({ tti, jsHeapUsedSize: metrics.JSHeapUsedSize });
结果(3次取平均):
| 方案 | TTI (ms) | JS 堆内存 (MB) | 总传输字节 (KB) |
|---|---|---|---|
| CSR | 4860 | 32 | 1350 |
| SSR | 4230 | 28 | 1120 |
| RSC | 1720 | 9 | 520 |
RSC 的 TTI 比 SSR 改善 59%,内存占用降低 68%。对于低端手机(如 Moto G4),RSC 的首次交互更加流畅。
六、避坑指南(我实际踩过的 6 个坑)
坑 1:服务端组件里用了 useState
现象:编译报错:Error: useState is not available in Server Components。
原因:服务端组件只能使用纯函数或 async 函数,不能使用状态、副作用、响应用户事件。
解决:把需要交互的部分拆到单独的文件,并添加 `'use client'`。
坑 2:对象序列化限制
现象:服务端组件向客户端组件传递的 props 中包含 `Date` 对象,报错:Error: Objects with type 'Date' are not serializable。
原因:RSC 的 props 传输基于 JSON 序列化(实际上使用特殊的序列化协议,但要求可序列化)。
解决:一律将 Date 转为字符串(`toISOString()`),在客户端再解析。
坑 3:第三方 UI 库未标记 "use client"
现象:在生产构建时报错:Error: Components with children must return a single element, or null 或其他诡异错误。
原因:很多 npm 库(如 antd、MUI)的组件内部使用了客户端 API,但它们提供的入口文件没有 `'use client'` 声明,被当作服务端组件处理。
解决:在引入这些库的组件文件顶部加 `'use client'`,或者使用 `next/dynamic` 动态导入并指定 `ssr: false` 或 `loading`。
坑 4:服务端组件内直接使用 fetch 导致缓存混乱
现象:页面数据不更新,或者出现“请求被重复发送”。
原因:Next.js 的 fetch API 自带缓存,默认对同一个请求路径使用缓存策略。
解决:需要动态数据的请求,可以加 `cache: 'no-store'` 或者使用 `revalidate` 选项。例如:`fetch(url, { next: { revalidate: 10 } })`。
坑 5:将敏感信息传给了客户端组件 props
现象:在浏览器 DevTools 中能看到服务端组件返回的 RSC Payload 里包含内部 API 密钥。
原因:所有客户端组件接收的 props 都会被序列化到 RSC Payload 中,并传输至客户端。
解决:确保传给客户端组件的数据都是公开的。如果需要内部计算,在服务端组件内完成,只传结果。
坑 6:错误边界处理不当导致白屏
现象:服务端组件中某个库抛错,整个页面直接 500,没有友好的错误提示。
原因:服务端组件的错误无法被客户端 ErrorBoundary 捕获(因为它们运行在 Node 环境)。
解决:在 app 目录下创建 `error.tsx`(客户端组件)作为全局错误边界,配合 `'use client'` 处理客户端错误;同时服务端错误可通过特定页面 `error.tsx` 或重定向处理。
七、总结
React Server Components 不是魔法,而是通过将组件树明确切分,解决了“全量 hydrate”的冗余开销。对于数据密集、交互较少的页面(列表页、文章详情页、仪表盘)效果显著。交互密集部分(表单、拖拽、实时编辑)则继续使用客户端组件。
如果你现在负责一个性能敏感的后台或者内容站点,强烈建议尝试 Next.js App Router + RSC。但务必先理解“序列化限制”和“use client 边界”这两个核心设计,否则会在调试上浪费大量时间。