React Server Components:心智模型、设计模式与常见陷阱
我在学习 React Server Components 时最希望存在的实战指南。心智模型、真实模式、边界问题,以及我犯过的错误——这样你就不必再踩这些坑了。
我用了整整三个月时间才意识到自己对 React Server Components 的理解基本都是错的。我读了 RFC,看了技术大会的演讲,也搭建了几个 demo 应用。我是那种"自信地犯着错"的状态。
问题不在于 RSC 有多复杂。而是它的心智模型与我们之前在 React 中做的任何事情都截然不同,每个人——包括我自己——都试图把它塞进旧的框架里。"它就像 SSR。"不是。"它就像 PHP。"更接近一点,但也不是。"它就是在服务端运行的组件。"技术上对,实际上没用。
接下来的内容是我真正需要知道的一切,用我希望别人曾经这样向我解释的方式来写。不是理论版本,而是那个你晚上 11 点盯着一个序列化错误、需要理解为什么的版本。
真正有效的心智模型#
暂时忘掉你对 React 渲染的所有认知。来看看新的图景。
在传统 React(客户端)中,你的整个组件树作为 JavaScript 发送到浏览器。浏览器下载它、解析它、执行它、渲染结果。每个组件——无论是一个 200 行的交互式表单还是一段静态文本——都经过同样的流水线。
React Server Components 将这分为两个世界:
服务端组件在服务器上运行。它们执行一次,产生输出,然后将结果发送到客户端——而不是代码。浏览器永远看不到组件函数,永远不会下载它的依赖,永远不会重新渲染它。
客户端组件像传统 React 一样工作。它们被发送到浏览器,进行 hydration,维护状态,处理事件。它们就是你已经熟悉的 React。
让我花了很长时间才内化的关键洞察:**服务端组件是默认的。**在 Next.js App Router 中,每个组件都是服务端组件,除非你用 "use client" 显式地将它切换到客户端。这与我们习惯的恰好相反,它改变了你对组合的思考方式。
渲染流程#
当用户请求一个页面时,实际发生了什么:
1. 请求到达服务器
2. 服务器自上而下执行服务端组件
3. 当服务端组件遇到 "use client" 边界时,
它停止——该子树将在客户端渲染
4. 服务端组件产生 RSC Payload(一种特殊格式)
5. RSC Payload 流式传输到客户端
6. 客户端渲染客户端组件,将它们缝合到
服务端渲染的树中
7. Hydration 使客户端组件变得可交互
第 4 步是大多数困惑所在。RSC Payload 不是 HTML。它是一种特殊的流式格式,描述了组件树——服务器渲染了什么,客户端需要在哪里接管,以及跨边界传递什么 props。
它大致看起来像这样(简化版):
M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]
你不需要记住这种格式。但理解它的存在——理解在服务器和客户端之间存在一个序列化层——将为你节省数小时的调试时间。每次你遇到"Props must be serializable"错误时,都是因为你传递的某些东西无法经受这种转换。
"在服务器上运行"的真正含义#
当我说服务端组件"在服务器上运行"时,我是字面意思。组件函数在 Node.js(或 Edge 运行时)中执行。这意味着你可以:
// app/dashboard/page.tsx — 默认就是服务端组件
import { db } from "@/lib/database";
import { headers } from "next/headers";
export default async function DashboardPage() {
const headerList = await headers();
const userId = headerList.get("x-user-id");
// 直接数据库查询。不需要 API 路由。
const user = await db.user.findUnique({
where: { id: userId },
});
const recentOrders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div>
<h1>Welcome back, {user.name}</h1>
<OrderList orders={recentOrders} />
</div>
);
}没有 useEffect。没有加载状态管理。没有 API 路由来粘合一切。组件就是数据层。这是 RSC 最大的收益,也是一开始让我最不舒服的地方,因为我总在想"但是分层在哪里?"
分层就是 "use client" 边界。它上面的一切是服务端。它下面的一切是客户端。这就是你的架构。
服务端/客户端边界#
这是大多数人的理解崩塌的地方,也是我在最初几个月花了最多调试时间的地方。
"use client" 指令#
文件顶部的 "use client" 指令将该文件中导出的所有内容标记为客户端组件。它是一个模块级别的注解,不是组件级别的。
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// 整个文件现在是"客户端领地"
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// 这也是客户端组件,因为它在同一个文件中
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}常见错误:在一个桶文件(index.ts)中添加 "use client",然后重新导出所有东西。恭喜,你刚刚让整个组件库都变成了客户端的。我见过团队因此意外发送了 200KB 的 JavaScript。
什么可以跨越边界#
这条规则能救你:跨越服务端-客户端边界的所有内容都必须可以序列化为 JSON。
可序列化的:
- 字符串、数字、布尔值、null、undefined
- 数组和普通对象(包含可序列化的值)
- Date(序列化为 ISO 字符串)
- 服务端组件(作为 JSX——我们稍后会讲到这个)
- FormData
- 类型化数组、ArrayBuffer
不可序列化的:
- 函数(包括事件处理器)
- 类(自定义类的实例)
- Symbol
- DOM 节点
- 流(在大多数情况下)
这意味着你不能这样做:
// app/page.tsx(服务端组件)
import { ItemList } from "@/components/ItemList"; // 客户端组件
export default async function Page() {
const items = await getItems();
return (
<ItemList
items={items}
// 错误:函数不可序列化
onItemClick={(id) => console.log(id)}
// 错误:类实例不可序列化
formatter={new Intl.NumberFormat("en-US")}
/>
);
}修复方法不是把页面变成客户端组件。修复方法是将交互性往下推,将数据获取往上提:
// app/page.tsx(服务端组件)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// 只传递可序列化的数据
return <ItemList items={items} locale="en-US" />;
}// src/components/ItemList.tsx(客户端组件)
"use client";
import { useState, useMemo } from "react";
interface Item {
id: string;
name: string;
price: number;
}
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// 在客户端创建 formatter
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}"岛屿"的误解#
我最初把客户端组件想象成"岛屿"——服务端渲染内容海洋中的小型交互片段。这部分是对的,但遗漏了一个关键细节:如果服务端组件作为 children 或 props 传递,客户端组件可以渲染服务端组件。
这意味着边界不是一堵硬墙。它更像一层膜。服务端渲染的内容可以通过 children 模式穿过客户端组件。我们将在组合部分深入探讨这一点。
数据获取模式#
RSC 从根本上改变了数据获取。对于在渲染时已知的数据,不再需要 useEffect + useState + 加载状态。但新模式也有自己的坑。
基础 Fetch 与缓存#
在服务端组件中,你只需 fetch。Next.js 扩展了全局 fetch 来添加缓存:
// app/products/page.tsx
export default async function ProductsPage() {
// 默认缓存——相同 URL 返回缓存结果
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}显式控制缓存行为:
// 每 60 秒重新验证(类似 ISR 的行为)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// 不缓存——始终获取新数据
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// 带标签缓存,用于按需重新验证
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});然后你可以通过标签从 Server Action 中重新验证:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}并行数据获取#
我见过的最常见性能错误:本可以并行获取数据却使用了串行。
差——串行(瀑布流):
// app/dashboard/page.tsx — 不要这样做
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// 总计: 650ms — 每个都等待前一个完成
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}好——并行:
// app/dashboard/page.tsx — 这样做
export default async function Dashboard() {
// 三个请求同时发出
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms(并行运行)
getNotifications(), // 150ms(并行运行)
]);
// 总计: ~300ms — 受最慢请求限制
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}更好——带独立 Suspense 边界的并行:
// app/dashboard/page.tsx — 最佳方案
import { Suspense } from "react";
export default function Dashboard() {
// 注意:这个组件不是 async 的——它委托给子组件
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// 每个组件获取自己的数据
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}最后这个模式最强大,因为每个部分独立加载。用户在内容可用时就能看到它,而不是全有或全无。快的部分不用等慢的。
请求去重#
Next.js 会在单次渲染过程中自动去重具有相同 URL 和选项的 fetch 调用。这意味着你不需要将数据获取提升到上层来避免冗余请求:
// 这两个组件可以获取相同的 URL
// Next.js 只会发出一个实际的 HTTP 请求
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welcome, {user.name}</nav>;
}
async function Sidebar() {
// 相同 URL — 自动去重,不是第二次请求
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}重要注意事项:这只对 fetch 有效。如果你直接使用 ORM 或数据库客户端,需要使用 React 的 cache() 函数:
import { cache } from "react";
import { db } from "@/lib/database";
// 用 cache() 包裹你的数据函数
// 现在同一次渲染中的多次调用 = 一次实际查询
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache() 在单个服务器请求的生命周期内去重。它不是持久缓存——而是按请求的记忆化。请求完成后,缓存的值会被垃圾回收。
组件组合模式#
这是 RSC 真正优雅的地方,一旦你理解了模式。在你理解之前,则是真正令人困惑的地方。
"Children 作为孔洞"模式#
这是 RSC 中最重要的组合模式,我花了几周才完全领会。问题是这样的:你有一个客户端组件提供某种布局或交互性,你想在里面渲染服务端组件。
你不能在客户端组件文件中导入服务端组件。一旦你添加了 "use client",该模块中的所有内容都是客户端的。但你可以将服务端组件作为 children 传递:
// src/components/Sidebar.tsx — 客户端组件
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* 这些 children 可以是服务端组件! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — 服务端组件
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // 服务端组件
import { UserProfile } from "@/components/UserProfile"; // 服务端组件
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* 这些是服务端组件,通过客户端组件传递 */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}为什么这能工作?因为服务端组件(UserProfile,NavigationLinks)首先在服务器上渲染,然后它们的输出(RSC payload)作为 children 传递给客户端组件。客户端组件永远不需要知道它们是服务端组件——它只是接收预渲染的 React 节点。
把 children 想象成客户端组件中的一个"孔洞",服务端渲染的内容可以通过它流过。
将服务端组件作为 Props 传递#
children 模式可以推广到任何接受 React.ReactNode 的 prop:
// src/components/TabLayout.tsx — 客户端组件
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — 服务端组件
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // 服务端组件 — 可以获取数据
import { BillingSettings } from "./BillingSettings"; // 服务端组件 — 可以获取数据
import { SecuritySettings } from "./SecuritySettings"; // 服务端组件 — 可以获取数据
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}每个设置组件都可以是一个异步服务端组件来获取自己的数据。客户端组件(TabLayout)只处理标签切换。这是一个极其强大的模式。
异步服务端组件#
服务端组件可以是 async 的。这非常重要,因为它意味着数据获取发生在渲染期间,而不是作为副作用:
// 这是合法且优雅的
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>By {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}客户端组件不能是 async 的。如果你试图让一个 "use client" 组件变成 async,React 会报错。这是一个硬性约束。
Suspense 边界:流式传输的原语#
Suspense 是 RSC 中实现流式传输的方式。没有 Suspense 边界,整个页面会等待最慢的异步组件。有了它们,每个部分独立流式传输:
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* 静态 — 立即渲染 */}
<HeroSection />
{/* 快速数据 — 很快显示 */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* 中等速度 — 准备好时显示 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* 慢速(ML 驱动)— 最后显示,不阻塞其他部分 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}用户立即看到 HeroSection,然后 ProductGrid 流式传入,然后评论,然后推荐。每个 Suspense 边界都是一个独立的流式传输点。
嵌套 Suspense 边界也是合法且有用的:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>如果 Dashboard 很快但 RevenueChart 很慢,外层 Suspense 先解析(显示仪表盘外壳),内层图表的 Suspense 稍后解析。
Error Boundary 与 Suspense#
将 Suspense 与 error.tsx 配对实现弹性 UI:
// app/dashboard/error.tsx — 客户端组件(必须是)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>加载仪表盘时出错了</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
重试
</button>
</div>
);
}error.tsx 文件自动将对应的路由段包裹在 React Error Boundary 中。如果该段中的任何服务端组件抛出异常,错误 UI 会显示而不是崩溃整个页面。
何时使用哪个:决策树#
在用 RSC 构建了几个生产应用之后,我形成了一个清晰的决策框架。以下是我为每个组件实际经历的思考过程:
从服务端组件开始(默认)#
每个组件都应该是服务端组件,除非有特定原因它不能是。这是最重要的规则。
在以下情况使其成为客户端组件:#
1. 它使用浏览器专有 API
"use client";
// window, document, navigator, localStorage 等
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}2. 它使用需要状态或副作用的 React hooks
useState、useEffect、useReducer、useRef(用于可变 ref)、useContext —— 任何这些都需要 "use client"。
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// 这个组件必须是客户端组件,因为它
// 使用了 useState 并管理用户输入
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}3. 它附加了事件处理器
onClick、onChange、onSubmit、onMouseEnter —— 任何交互行为都意味着客户端。
4. 它使用客户端库
Framer Motion、React Hook Form、Zustand、React Query(用于客户端获取)、任何交互式渲染到 canvas 或 SVG 的图表库。
在以下情况保持为服务端组件:#
- 它只显示数据(无用户交互)
- 它从数据库或 API 获取数据
- 它访问后端资源(文件系统、带密钥的环境变量)
- 它导入客户端不需要的大型依赖(markdown 解析器、语法高亮器、日期格式化库)
- 它渲染静态或半静态内容
实际中的决策#
这是一个具体的例子。我正在构建一个产品页面:
ProductPage(服务端)
├── ProductBreadcrumbs(服务端)— 静态导航,无交互
├── ProductImageGallery(客户端)— 缩放、滑动、灯箱
├── ProductInfo(服务端)
│ ├── ProductTitle(服务端)— 只是文本
│ ├── ProductPrice(服务端)— 格式化数字,无交互
│ └── AddToCartButton(客户端)— onClick,管理购物车状态
├── ProductDescription(服务端)— 渲染的 markdown
├── Suspense
│ └── RelatedProducts(服务端)— 异步数据获取,慢 API
└── Suspense
└── ProductReviews(服务端)
└── ReviewForm(客户端)— 带验证的表单
注意这个模式:页面外壳和数据密集的部分是服务端组件。交互岛屿(ImageGallery、AddToCartButton、ReviewForm)是客户端组件。慢的部分(RelatedProducts、ProductReviews)被包裹在 Suspense 中。
这不是理论。这就是我的组件树实际的样子。
常见错误(我全都犯过)#
错误 1:把所有东西都变成客户端组件#
从 Pages Router 或 Create React App 迁移时,阻力最小的方式是在所有东西上加 "use client"。它能工作!什么都不会坏!但你也把整个组件树作为 JavaScript 发送了出去,得到了零 RSC 收益。
我见过根布局上有 "use client" 的代码库。到了那个程度,你实际上就是在多走了几步的情况下运行一个客户端 React 应用。
修复:从服务端组件开始。只在编译器告诉你需要时(因为你使用了 hook 或事件处理器)添加 "use client"。尽可能将 "use client" 推到树的叶子节点。
错误 2:通过边界层层传递 Props#
// 差:在服务端组件中获取数据,然后通过多个客户端组件传递
// app/page.tsx(服务端)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}你通过边界传递的每一份数据都会被序列化进 RSC payload。传递同一个对象五次?它在 payload 中出现五次。我见过因为这个原因 RSC payload 膨胀到几兆字节的情况。
修复:使用组合。将服务端组件作为 children 传递,而不是将数据作为 props 传递:
// 好:服务端组件获取自己的数据,作为 children 传递
// app/page.tsx(服务端)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* 服务端组件 — 获取自己的数据 */}
<Settings /> {/* 服务端组件 — 获取自己的数据 */}
<ClientWidget>
<UserAvatar /> {/* 服务端组件 — 获取自己的数据 */}
</ClientWidget>
</ClientShell>
);
}错误 3:不使用 Suspense#
没有 Suspense,你的页面的 TTFB 受限于最慢的数据获取。我有一个仪表盘页面加载需要 4 秒,因为一个分析查询很慢,尽管页面其余数据在 200ms 内就准备好了。
// 差:所有东西都等待所有东西
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← 阻塞一切
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// 好:analytics 独立加载
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* 需要 4 秒但不阻塞其他部分 */}
</Suspense>
</div>
);
}错误 4:运行时的序列化错误#
这个特别痛苦,因为你经常直到生产环境才发现。你跨边界传递了不可序列化的东西,得到一个晦涩的错误:
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
常见罪魁祸首:
- 传递 Date 对象(改用
.toISOString()) - 传递 Map 或 Set(转换为数组/对象)
- 传递 ORM 的类实例(使用
.toJSON()或展开为普通对象) - 传递函数(将逻辑移到客户端组件或使用 Server Actions)
- 传递带有
Decimal字段的 Prisma 模型结果(转换为number或string)
// 差
const user = await prisma.user.findUnique({ where: { id } });
// user 可能有不可序列化的字段(Decimal、BigInt 等)
return <ClientProfile user={user} />;
// 好
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;错误 5:所有东西都用 Context#
useContext 只在客户端组件中工作。如果你试图在服务端组件中使用 React context,它不会工作。我见过人们仅仅为了使用主题 context 就把整个应用变成客户端组件。
修复:对于主题和其他全局状态,使用在服务端设置的 CSS 变量,或使用 cookies() / headers() 函数:
// app/layout.tsx(服务端组件)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}对于真正的客户端状态(认证令牌、购物车、实时数据),在适当的层级创建一个薄的客户端组件 Provider——不是根级别:
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}// app/shop/layout.tsx(服务端组件)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider 是客户端组件,但 children 作为服务端内容流过
return <CartProvider>{children}</CartProvider>;
}错误 6:忽视包大小影响#
RSC 最大的收益之一是服务端组件的代码永远不会发送到客户端。但你需要主动思考这一点。如果你有一个使用 50KB markdown 解析器且只显示渲染后内容的组件——它应该是服务端组件。解析器留在服务器上,只有 HTML 输出到客户端。
// 服务端组件 — marked 留在服务器上
import { marked } from "marked"; // 50KB 库 — 永远不会发送到客户端
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}如果你把它变成客户端组件,marked 就会被发送到浏览器。白白浪费。用户会下载 50KB 的 JavaScript,仅仅是为了渲染本来就可以是 HTML 的内容。
用 @next/bundle-analyzer 检查你的包。结果可能会让你大吃一惊。
缓存策略#
Next.js 15+ 中的缓存相比早期版本已经大幅简化,但仍有不同的层级需要理解。
cache() 函数(React)#
React 的 cache() 用于按请求去重,不是持久缓存:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// 在单个请求期间,在组件树的任何位置调用它。
// 只有一个实际的数据库查询会执行。这作用域限于单个服务器请求。请求完成后,缓存值就没了。这是记忆化,不是缓存。
unstable_cache(Next.js)#
对于跨请求的持久缓存,使用 unstable_cache(名字一直是 "unstable",但它在生产中工作得很好):
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // 缓存键前缀
{
revalidate: 3600, // 每小时重新验证
tags: ["products"], // 用于按需重新验证
}
);
// 在服务端组件中使用
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}要使缓存失效:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}静态 vs 动态渲染#
Next.js 根据你使用的内容来决定路由是静态的还是动态的:
静态(在构建时渲染,缓存):
- 没有动态函数(
cookies()、headers()、searchParams) - 所有
fetch调用都启用了缓存 - 没有
export const dynamic = "force-dynamic"
动态(每次请求时渲染):
- 使用
cookies()、headers()或searchParams - 使用
fetch配合cache: "no-store" - 有
export const dynamic = "force-dynamic" - 使用来自
next/server的connection()或after()
你可以通过运行 next build 来检查哪些路由是静态的、哪些是动态的——它在底部显示一个图例:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Static ● SSG λ Dynamic
缓存层次结构#
将缓存按层次思考:
1. React cache() — 按请求,内存中,自动去重
2. fetch() 缓存 — 跨请求,GET 请求自动缓存
3. unstable_cache() — 跨请求,用于非 fetch 操作
4. 完整路由缓存 — 在构建/重新验证时缓存渲染后的 HTML
5. 路由器缓存(客户端) — 浏览器中已访问路由的缓存
每一层都有不同的用途。你不一定需要所有层,但理解哪一层是活跃的有助于调试"为什么我的数据没有更新?"的问题。
实际的缓存策略#
这是我在生产中实际做的:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// 按请求去重:在一次渲染中多次调用,
// 只运行一次数据库查询
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// 跨请求缓存:结果在请求间持久化,
// 每 5 分钟或通过标签按需重新验证
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// 无缓存:始终最新(用于用户特定数据)
export const getUserCart = cache(async (userId: string) => {
// 这里的 cache() 只用于按请求去重,不是持久化
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});经验法则:不经常变化的公开数据用 unstable_cache。用户特定数据只用 cache() 进行去重。实时数据完全不缓存。
Server Actions:返回的桥梁#
Server Actions 值得单独讲一节,因为它们完善了 RSC 的故事。它们是客户端组件不通过 API 路由就能与服务器通信的方式。
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Invalid email address" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Already subscribed" };
}
return { error: "Something went wrong" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="you@example.com"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Subscribed!</p>}
</form>
);
}Server Actions 是 RSC 世界中"如何修改数据?"的答案。它们替代了大部分用于表单提交、数据变更和副作用的 API 路由。
Server Actions 的关键规则:
- 始终验证输入(函数可以从客户端调用——把它当作 API 端点对待)
- 始终返回可序列化的数据
- 它们在服务器上运行,所以你可以访问数据库、文件系统、密钥
- 它们可以调用
revalidatePath()或revalidateTag()在变更后更新缓存数据
迁移模式#
如果你有一个现有的 React 应用(Pages Router、Create React App、Vite),迁移到 RSC 不必是一次重写。以下是我的方法。
步骤 1:映射你的组件#
遍历你的组件树,将所有东西分类:
组件 状态? 副作用? 事件? → 决策
─────────────────────────────────────────────────────────
Header 否 否 否 → 服务端
NavigationMenu 否 否 是 → 客户端(移动端切换)
Footer 否 否 否 → 服务端
BlogPost 否 否 否 → 服务端
SearchBar 是 是 是 → 客户端
ProductCard 否 否 是 → 客户端(onClick)或拆分
UserAvatar 否 否 否 → 服务端
CommentForm 是 是 是 → 客户端
Sidebar 是 否 是 → 客户端(折叠切换)
MarkdownRenderer 否 否 否 → 服务端(大的依赖收益)
DataTable 是 是 是 → 客户端(排序、过滤)
步骤 2:将数据获取上移#
最大的架构变化是将数据获取从组件中的 useEffect 移到 async 服务端组件中。这是真正的迁移工作所在。
之前:
// 旧模式 — 在客户端组件中获取数据
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}之后:
// 新模式 — 服务端组件获取,客户端组件交互
// app/products/[id]/page.tsx(服务端组件)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}没有加载状态管理。没有错误状态。没有 useEffect。框架通过 Suspense 和 error boundary 处理所有这些。
步骤 3:在交互边界拆分组件#
许多组件大部分是静态的,只有一小部分是交互式的。拆分它们:
之前(一个大的客户端组件):
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}之后(服务端组件加一个小的客户端岛屿):
// ProductCard.tsx(服务端组件)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx(客户端组件)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}图片、标题、描述和价格现在都是服务端渲染的。只有那个微小的收藏按钮是客户端组件。更少的 JavaScript,更快的页面加载。
步骤 4:将 API 路由转换为 Server Actions#
如果你有仅为自己的前端服务(而非外部消费者)的 API 路由,大部分可以变成 Server Actions:
之前:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// 验证,保存到数据库,发送邮件
return Response.json({ success: true });
}
// 客户端组件
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});之后:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// 验证,保存到数据库,发送邮件
return { success: true };
}
// 客户端组件 — 直接调用函数
import { submitContactForm } from "@/app/actions/contact";保留 API 路由用于:webhooks、外部 API 消费者、需要自定义 HTTP 头或状态码的场景、流式文件上传。
步骤 5:测试 RSC 组件#
测试服务端组件需要稍微不同的方法,因为它们可以是异步的:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock 数据库
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("renders product details", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Test Product",
description: "A great product",
price: 29.99,
});
// 服务端组件是异步的 — await JSX
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("A great product")).toBeInTheDocument();
});
});关键区别:你 await 组件函数因为它是异步的。然后渲染得到的 JSX。其他一切与传统 React Testing Library 的用法相同。
RSC 项目的文件结构#
这是我在多个项目后收敛到的结构。它有偏见,但它有效:
src/
├── app/
│ ├── layout.tsx ← 根布局(服务端组件)
│ ├── page.tsx ← 首页(服务端组件)
│ ├── (marketing)/ ← 营销页面的路由组
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← 需要认证的应用路由组
│ │ ├── layout.tsx ← 带认证检查的应用外壳
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← 该路由的 Suspense 回退
│ │ │ └── error.tsx ← 该路由的错误边界
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← 共享 UI 原语(大多是客户端)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← 服务端组件(仅样式)
│ └── features/ ← 功能特定组件
│ ├── products/
│ │ ├── ProductGrid.tsx ← 服务端(异步,获取数据)
│ │ ├── ProductCard.tsx ← 服务端(展示)
│ │ ├── ProductSearch.tsx ← 客户端(useState,输入)
│ │ └── AddToCart.tsx ← 客户端(onClick,变更)
│ └── blog/
│ ├── PostList.tsx ← 服务端(异步,获取数据)
│ ├── PostContent.tsx ← 服务端(markdown 渲染)
│ └── CommentSection.tsx ← 客户端(表单,实时)
├── lib/
│ ├── data/ ← 数据访问层
│ │ ├── products.ts ← cache() 包裹的数据库查询
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — 包裹需要主题的部分
└── CartProvider.tsx ← "use client" — 仅包裹商店部分
关键原则:
- 服务端组件没有指令 — 它们是默认的
- 客户端组件被显式标记 — 一眼就能看出来
- 数据获取放在
lib/data/中 — 用cache()或unstable_cache包裹 - Server Actions 放在
app/actions/中 — 与应用共置,清晰分离 - Provider 包裹最小必要范围 — 不是整个应用
总结#
React Server Components 不仅仅是一个新的 API。它们是关于代码在哪里运行、数据在哪里存在、以及各部分如何连接的不同思考方式。心智模型的转变是真实的,它需要时间。
但一旦它"啪"地一下通了——一旦你停止与边界对抗,开始围绕它设计——你最终得到的应用比我们之前构建的更快、更简单、更易维护。更少的 JavaScript 发送到客户端。数据获取不需要繁文缛节。组件树就是架构。
这个转变是值得的。只是要知道前几个项目会感觉不舒服,这很正常。你不是因为 RSC 不好而在挣扎,你是因为它真的是全新的东西而在挣扎。
从服务端组件开始。将 "use client" 推到叶子节点。慢的东西用 Suspense 包裹。在渲染的地方获取数据。通过 children 进行组合。
这就是整个策略手册。其他都是细节。