React Server Components 生产实战:架构模式、性能优化与15个常见陷阱
RSC 摧毁我们心智模型的那一周
我们把一个200个路由的仪表盘迁移到 React Server Components。第一周完全是灾难。开发者反复问:"useState 去哪了?""为什么导不进来这个组件?""页面渲染出来了但点击没反应。"三次生产事故之后,我们意识到问题不在 React——而在我们的心智模型。
React Server Components 不是"SSR 2.0"。它是一种完全不同的执行模型:组件只在服务器上运行,其 JavaScript 永远不会发送到客户端。这篇文章把六个月的踩坑经验浓缩成可操作的架构模式和避坑指南。
心智模型转变:Server ≠ SSR
最让人困惑的地方:RSC 不是 SSR。用一张表来理清:
| 概念 | 代码在哪运行 | 是否发送JS到客户端 | 能访问什么 |
|---|---|---|---|
| SSR(传统) | 请求时在服务端 | 是(水合用的bundle) | 请求上下文、数据库 |
| Client Component | 水合后在客户端 | 是(完整bundle) | 浏览器API、state、effects |
| Server Component(RSC) | 仅服务端,从不到客户端 | 否 | 数据库、文件系统、密钥 |
| "use client" 边界 | 标记 服务端→客户端 转换点 | 是的 | 它是一条指令,不是运行时 |
核心洞察:Server Component 让你把数据库查询和查询结果都留在服务器上。你不会把敏感数据序列化到 props 里,不会把 ORM 打到浏览器里。组件从数据库读取数据,渲染成 React 树,只有渲染输出到达客户端。
// 整个函数在服务器上运行
// 零字节的 JS 发送到浏览器
async function UserProfile({ userId }: { userId: string }) {
// 直接访问数据库——不需要 API 路由,不需要 fetch,不需要客户端数据请求
const user = await db.user.findUnique({
where: { id: userId },
include: { posts: true, settings: true },
});
// 密钥、token、环境变量——在这里完全安全
const avatarUrl = await s3.getSignedUrl(`avatars/${user.avatarKey}`);
// 只有渲染后的 HTML 到达客户端
return (
<div>
<h1>{user.name}</h1>
<Avatar url={avatarUrl} /> {/* 这可以是个 Client Component */}
<PostList posts={user.posts} />
</div>
);
}
对比旧模式:getServerSideProps 把所有数据序列化成 JSON,客户端组件再通过 API 路由重新获取头像 URL。仅减少的网络瀑布就值得迁移。
Server/Client 边界:这才是难的地方
"use client" 指令标记了边界。它在导入树中以上的所有内容运行在服务端,以下的内容运行在客户端。听起来简单,但影响会渗透你的整个架构。
规则一:Server Component 可以导入 Client Component
// page.tsx — Server Component(无指令)
import { InteractiveChart } from "./InteractiveChart"; // Client Component
import { fetchChartData } from "@/lib/data";
export default async function DashboardPage() {
const data = await fetchChartData(); // 在服务器上运行
return (
<div>
<h1>销售仪表盘</h1>
{/* Server Component 把服务端获取的数据传给 Client Component */}
<InteractiveChart data={data} />
</div>
);
}
// InteractiveChart.tsx — Client Component
"use client";
import { LineChart } from "recharts";
import { useState } from "react";
export function InteractiveChart({ data }: { data: ChartData[] }) {
const [range, setRange] = useState<"7d" | "30d" | "90d">("7d");
return (
<div>
<select value={range} onChange={(e) => setRange(e.target.value as any)}>
<option value="7d">7天</option>
<option value="30d">30天</option>
<option value="90d">90天</option>
</select>
<LineChart data={filterByRange(data, range)}>{/* ... */}</LineChart>
</div>
);
}
这个模式——"服务端拿数据,客户端做交互"——替代了 80% 以前靠 useEffect + fetch 写的模板代码。
规则二:Client Component 不能直接导入 Server Component
这样写不行:
// ❌ Client Component 试图导入 Server Component
"use client";
import { ServerOnlyWidget } from "./ServerOnlyWidget"; // 报错
但可以通过 children 模式组合:
// ✅ 从父级 Server Component 以 children 方式传入
// page.tsx(Server Component)
import { ClientShell } from "./ClientShell";
import { ServerWidget } from "./ServerWidget";
export default function Page() {
return (
<ClientShell>
<ServerWidget /> {/* 服务端渲染,作为预渲染的 children 传入 */}
</ClientShell>
);
}
// ClientShell.tsx
"use client";
import { useState } from "react";
export function ClientShell({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className={collapsed ? "w-16" : "w-64"}>
<button onClick={() => setCollapsed(!collapsed)}>折叠</button>
{children} {/* ServerWidget 渲染后的内容出现在这里 */}
</div>
);
}
这个"插槽"模式是交织服务端和客户端内容的主要手段。我管它叫 特洛伊木马模式:Client Component 是木马,Server Component 是里面的士兵。
流式渲染:决定性能上限的特性
RSC 真正的大杀器不是服务端渲染本身——而是流式输出。服务器不用等所有数据都就绪才发第一个字节,而是一块一块地把页面流式传输。
架构示意
HTTP 响应流:
┌─────────────────────────────────────────────────────────────┐
│ <!DOCTYPE html><head>...</head><body> │
│ <Header /> ← 立即发送(静态内容) │
│ ── Suspense 边界 ── │
│ <Skeleton /> ← 先展示骨架屏 │
│ ── 200ms 后 ── │
│ <ProductList /> ← 数据就绪后替换骨架屏 │
│ ── Suspense 边界 ── │
│ <Skeleton /> │
│ ── 800ms 后 ── │
│ <Reviews /> ← 数据就绪后替换骨架屏 │
│ </body></html> │
└─────────────────────────────────────────────────────────────┘
实现代码
import { Suspense } from "react";
import { ProductList, ProductListSkeleton } from "./ProductList";
import { Reviews, ReviewsSkeleton } from "./Reviews";
import { Recommendations } from "./Recommendations";
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* 静态内容立即在流中发送 */}
<Header />
<Breadcrumb productId={params.id} />
{/* 外壳先发送,内容后流式到达 */}
<Suspense fallback={<ProductListSkeleton />}>
<ProductList productId={params.id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={params.id} />
</Suspense>
{/* 甚至可以嵌套 Suspense 边界 */}
<Suspense fallback={<div>正在加载推荐...</div>}>
<Recommendations productId={params.id} />
</Suspense>
</div>
);
}
我们观察到的关键指标改善:TTFB 基本持平(~150ms),但 LCP 从 2.8s 降到 0.9s。用户更快看到有意义的页面内容,因为页面是渐进式流式传输的。
流式渲染 + PPR(部分预渲染)
2026年,Next.js 支持 Partial Prerendering,可以在单个 HTTP 响应中混合静态和动态内容:
// 外壳是静态的(构建时预渲染)
// Suspense 空洞是动态的(请求时流式填充)
export default function Page() {
return (
<div className="static-shell">
{/* 这些是静态的——构建时预渲染 */}
<StaticNav />
<StaticSidebar />
{/* 这个空洞流式填充动态内容 */}
<Suspense fallback={<CartSkeleton />}>
<DynamicCart /> {/* 用户专属购物车数据 */}
</Suspense>
</div>
);
}
PPR 消除了静态生成和动态内容之间的权衡。营销页面享受即时的静态交付;个性化部分流式加载而不阻塞页面。
Server Actions:强大的能力,危险的陷阱
Server Actions 让你可以从表单提交和事件处理中直接调用服务端函数。无需 API 路由,无需 fetch,无需序列化模板代码。
正确用法
// actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().default(false),
});
export async function createPost(formData: FormData) {
const session = await getServerSession();
if (!session?.user) {
return { error: "未授权访问" };
}
// 永远在服务端做校验——不要信任客户端输入
const parsed = CreatePostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
published: formData.get("published") === "true",
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
const post = await db.post.create({
data: {
...parsed.data,
authorId: session.user.id,
},
});
revalidatePath("/posts");
return { postId: post.id };
}
// CreatePostForm.tsx
"use client";
import { useFormStatus } from "react-dom";
import { createPost } from "./actions";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "发布中..." : "发布文章"}
</button>
);
}
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" required maxLength={200} />
<textarea name="content" required minLength={10} />
<label>
<input type="checkbox" name="published" />
立即发布
</label>
<SubmitButton />
</form>
);
}
Server Actions 常见反模式
反模式一:在 useEffect 里调用 Server Action
// ❌ Server Action 不是给你在 effect 里做数据查询的
"use client";
import { fetchPosts } from "./actions";
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetchPosts().then(setPosts);
}, []);
return <div>{/* 渲染文章列表 */}</div>;
}
Server Components 才是拿来做数据获取的。Server Actions 是拿来搞变更的——创建、更新、删除。
反模式二:在 Server Action 里暴露敏感逻辑
// ❌ 这个 Server Action 的管理员检查可以被绕过
"use server";
export async function deleteUser(userId: string) {
// 永远不要跳过 Server Action 里的权限校验
await db.user.delete({ where: { id: userId } });
}
反模式三:巨大的序列化闭包
// ❌ 整个模块的依赖都变成了闭包的一部分
"use server";
import { heavyLibrary } from "heavy-lib"; // 2MB 的序列化闭包!
export async function doThing() {
heavyLibrary.process();
}
Server Actions 会序列化闭包。大体积的导入会增加发送到客户端的载荷。保持 Server Actions 精简。
15个最常见的 RSC 陷阱
陷阱1:在 Server Component 中使用 Hooks
// ❌ Server Component 里没有 useState、useEffect、useContext
export default function Page() {
const [count, setCount] = useState(0); // 报错
// ...
}
修复:把交互部分抽成 Client Component,或用 URL search params 管理状态。
陷阱2:跨边界传递函数作为 Props
// ❌ Server Component 试图把函数传给 Client Component
export default function Page() {
const handleClick = () => { /* 运行在服务端,序列化失败 */ };
return <ClientButton onClick={handleClick} />; // 报错
}
函数不可序列化。用 Server Actions 或在 Client Component 内部定义事件处理器。
陷阱3:忘记给事件处理器加 "use client"
// ❌ onClick 必须放在 Client Component 里
export default function Button() {
return <button onClick={() => alert("clicked")}>点击</button>; // 报错
}
陷阱4:Server Component 中过度查询
// ❌ 只需要 id 和 name,却 SELECT *
const users = await db.user.findMany(); // 取了所有列
// ✅ 只查需要渲染的字段
const users = await db.user.findMany({
select: { id: true, name: true },
});
陷阱5:没用 React.cache() 做请求去重
// ✅ 在单次渲染 pass 中去重 DB 调用
import { cache } from "react";
const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
// 同一次请求中三个不同的 Server Component 都调用,
// 但实际只执行一次数据库查询
export async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId);
return <div>{user.name}</div>;
}
export async function UserSettings({ userId }: { userId: string }) {
const user = await getUser(userId); // 命中缓存——不会发起第二次查询
return <div>{user.email}</div>;
}
陷阱6:Client Component 树过大
"use client" 边界下的每个组件,其 JavaScript 都会被发送到浏览器。把边界尽量往下推
// ❌ 整个页面都成了 Client Component
"use client";
export default function Page() {
return (
<div>
<StaticHeader />
<StaticContent />
<LikeButton /> {/* 只有这个需要交互 */}
<StaticFooter />
</div>
);
}
// ✅ 只有交互部分才是 Client Component
// page.tsx — Server Component
import { LikeButton } from "./LikeButton";
export default function Page() {
return (
<div>
<StaticHeader /> {/* Server Component */}
<StaticContent /> {/* Server Component */}
<LikeButton /> {/* 只有这个发送 JS */}
<StaticFooter /> {/* Server Component */}
</div>
);
}
陷阱7:Date/Time 序列化问题
// ❌ Date 对象跨边界序列化不干净
const post = await db.post.findUnique({ where: { id } });
return <ClientTimeline post={post} />; // post.createdAt 是 Date 对象
// ✅ 跨边界前把 Date 转成字符串
const post = await db.post.findUnique({ where: { id } });
return (
<ClientTimeline
post={{
...post,
createdAt: post.createdAt.toISOString(), // 字符串,不是 Date
}}
/>
);
陷阱8:CSS-in-JS 库不兼容 RSC
很多 CSS-in-JS 库(emotion、styled-components、旧版 Material UI)需要运行时 JavaScript,在 Server Component 里无法工作。换用 Tailwind CSS、CSS Modules,或零运行时方案(Panda CSS、Vanilla Extract)。
陷阱9:在 Server Component 中使用 Context Provider
// ❌ React.createContext / Provider 是客户端特性
export default function Layout({ children }) {
return (
<ThemeProvider>
{children}
</ThemeProvider>
);
}
// ✅ 把 Provider 包在 Client Component 里
// ProviderWrapper.tsx
"use client";
export function ProviderWrapper({ children }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
// layout.tsx
export default function Layout({ children }) {
return <ProviderWrapper>{children}</ProviderWrapper>;
}
陷阱10:忽略 Loading 和 Error 边界
Server Component 可能抛异常。没有错误边界,一次失败的数据库查询就能让整个页面崩溃。永远给依赖数据的区块配上 error.tsx(Next.js)或 <ErrorBoundary>。
陷阱11:变更后缓存了过期数据
// 变更后,通知 Next.js 刷新缓存
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProfile(data: FormData) {
await db.user.update({ /* ... */ });
revalidatePath("/profile"); // 刷新这一页
revalidateTag("user-settings"); // 刷新所有打了 'user-settings' 标签的数据
}
陷阱12:同一条路由上混用静态和动态渲染
一次 cookies() 或 headers() 调用就让整条路由变成动态渲染。用 PPR(部分预渲染)或把动态部分隔离在 Suspense 边界内。
陷阱13:嵌套 Suspense 边界导致布局偏移
当嵌套的 Suspense 解析完成时,可能把下面的内容挤下去。务必提供预留期望空间的 fallback:
<Suspense fallback={<div style={{ minHeight: "400px" }}><Skeleton /></div>}>
<DynamicContent />
</Suspense>
陷阱14:忘记 "use server" 也是一条指令
"use server" 标记一个文件包含 Server Actions。和 "use client" 不同,它不创建客户端边界——它创建的是可调用的服务端点。它们是不同的机制。
陷阱15:不衡量迁移效果
迁移前:测量 bundle 大小(@next/bundle-analyzer)、Lighthouse、Core Web Vitals。迁移后:再测一次。我们仪表盘的首次加载 JavaScript 减少了 64%。没有指标就是在盲猜。
缓存失效策略
RSC 缓存在四个层面上运作:
| 缓存层 | 缓存什么 | 持续时长 | 如何失效 |
|---|---|---|---|
| Router Cache | 浏览器中的 RSC payload | 单次会话 / 30s | router.refresh(),导航 |
| Full Route Cache | 服务端渲染的 HTML | 直到被重新验证 | revalidatePath(),revalidateTag() |
| Data Cache | 服务端 fetch() 的结果 | 直到被重新验证 | fetch(url, { next: { revalidate } }),revalidateTag() |
| Request Memoization | 单次渲染中的 fetch 去重 | 单次请求 | 自动 |
我们的生产缓存策略
// lib/data.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
// 第一层:请求内去重(React cache 自动)
const getProductInternal = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { variants: true, reviews: { take: 10 } },
});
});
// 第二层:带标签的持久缓存
export const getProduct = unstable_cache(
getProductInternal,
["product"], // 缓存键前缀
{
tags: ["products"], // 标签,用于批量失效
revalidate: 3600, // 即使没有变更,每小时重新验证一次
}
);
// 在变更操作中:
export async function updateProduct(id: string, data: FormData) {
await db.product.update({ where: { id }, data: parseFormData(data) });
revalidateTag("products"); // 使所有产品查询缓存失效
revalidatePath(`/products/${id}`); // 同时使已渲染页面失效
}
从 Pages Router 迁移:真实作战计划
我们花了三个月迁移了200+个页面。下面是一套经过验证的方案:
阶段一:共存(第1-2周)
Next.js 同时支持两种路由。新功能走 App Router,老页面保持 Pages Router:
src/app/ ← App Router(RSC,新代码)
src/pages/ ← Pages Router(现有代码,不变)
阶段二:搬家(第3-6周)
逐页迁移:
- 从
src/pages/搬到src/app/ - 把
getServerSideProps替换为 async Server Component - 把
getStaticProps+getStaticPaths替换为generateStaticParams - 只在需要交互的地方加
"use client"
// 迁移前(Pages Router)
export async function getServerSideProps(ctx) {
const post = await db.post.findUnique({ where: { id: ctx.params.id } });
return { props: { post: JSON.parse(JSON.stringify(post)) } };
}
export default function PostPage({ post }) {
const [liked, setLiked] = useState(false);
return (/* ... */);
}
// 迁移后(App Router — Server Component)
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await db.post.findUnique({ where: { id: params.id } });
const serialized = { ...post, createdAt: post.createdAt.toISOString() };
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} />
<LikeButton post={serialized} />
</article>
);
}
// LikeButton.tsx — 独立的 Client Component
"use client";
export function LikeButton({ post }) {
const [liked, setLiked] = useState(false);
return (/* ... */);
}
阶段三:优化(第7-12周)
- 给每个依赖数据的区块加 Suspense 边界
- 加
loading.tsx做路由级加载态 - 加
error.tsx做优雅的错误处理 - 逐页测量 bundle 大小缩减
迁移坑点
next/router→next/navigation:useRouter现在从next/navigation导入,API 不一样next/head→ Metadata API:静态<Head>标签改为导出generateMetadata()_app.tsx→layout.tsx:Layout 是嵌套而非包裹;导航间状态可以持久保留req/res对象:Server Component 里没有了。用headers()和cookies()代替
决策矩阵:什么场景用什么
| 场景 | 用 | 原因 |
|---|---|---|
| 静态内容(博客、文档) | Server Component + 静态生成 | 构建时渲染,零服务端成本 |
| 个性化内容(仪表盘) | Server Component + 动态渲染 | 每次请求获取用户数据 |
| 实时更新(聊天、通知) | Client Component + WebSocket | 不刷新页面就能更新 |
| 表单提交 | Server Action | 无需 API 路由,渐进增强 |
| 高交互性(画图编辑器) | Client Component | 大量 DOM 操作、拖拽 |
| 混合静态+动态(电商) | PPR + Suspense | 静态外壳 + 动态空洞 |
我们迁移后的性能数据
仪表盘从 Pages Router 迁移到 RSC 后的实测数据:
| 指标 | 迁移前(Pages Router) | 迁移后(RSC) | 变化 |
|---|---|---|---|
| 首次加载 JS(解析后) | 843 KB | 297 KB | -64.7% |
| Largest Contentful Paint(P75) | 2.8s | 0.9s | -67.9% |
| Time to Interactive | 3.4s | 1.2s | -64.7% |
| First Input Delay(P75) | 87ms | 12ms | -86.2% |
| Cumulative Layout Shift | 0.21 | 0.03 | -85.7% |
| 单请求服务端 CPU | 45ms | 32ms | -28.9% |
| 全站构建时间 | 4分12秒 | 2分48秒 | -33.3% |
客户端 JavaScript 的大幅减少是性能提升的主要驱动力。我们干掉了之前打包进水合的所有依赖树:Prisma client(380KB)、date-fns(67KB)、zod(45KB),以及各种工具库。
总结:React Server Components 是自 hooks 以来 React 最大的范式转变。成功的关键不在于技术知识——而在于心智模型:Server Component 负责获取和渲染;Client Component 负责交互。把边界尽可能往组件树的叶子推。用流式渲染渐进交付内容。在 Server Actions 里永远做服务端校验。善用
unstable_cache和revalidateTag做激进缓存。迁移前后都要测量。性能收益是实打实的,但需要纪律。
在线工具
- JSON 格式化 — 格式化和校验 Server/Client Component 之间交换的 JSON 数据
- Base64 编解码 — 处理跨 RSC 序列化边界传递的二进制数据
- 代码截图 — 截取和分享你的 RSC 组件代码
本站提供浏览器本地工具,免注册即可试用 →