跳至内容
·15 分钟阅读

React Server Components:心智模型、设计模式与常见陷阱

我在学习 React Server Components 时最希望存在的实战指南。心智模型、真实模式、边界问题,以及我犯过的错误——这样你就不必再踩这些坑了。

分享:X / TwitterLinkedIn

我用了整整三个月时间才意识到自己对 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 运行时)中执行。这意味着你可以:

tsx
// 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" 指令将该文件中导出的所有内容标记为客户端组件。它是一个模块级别的注解,不是组件级别的。

tsx
// 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 节点
  • 流(在大多数情况下)

这意味着你不能这样做:

tsx
// 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")}
    />
  );
}

修复方法不是把页面变成客户端组件。修复方法是将交互性往下推,将数据获取往上提:

tsx
// app/page.tsx(服务端组件)
import { ItemList } from "@/components/ItemList";
 
export default async function Page() {
  const items = await getItems();
 
  // 只传递可序列化的数据
  return <ItemList items={items} locale="en-US" />;
}
tsx
// 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 来添加缓存:

tsx
// 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>
  );
}

显式控制缓存行为:

tsx
// 每 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 中重新验证:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function refreshProducts() {
  revalidateTag("products");
}

并行数据获取#

我见过的最常见性能错误:本可以并行获取数据却使用了串行。

差——串行(瀑布流):

tsx
// 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>
  );
}

好——并行:

tsx
// 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 边界的并行:

tsx
// 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 调用。这意味着你不需要将数据获取提升到上层来避免冗余请求:

tsx
// 这两个组件可以获取相同的 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() 函数:

tsx
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 传递:

tsx
// 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>
  );
}
tsx
// 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>
  );
}

为什么这能工作?因为服务端组件(UserProfileNavigationLinks)首先在服务器上渲染,然后它们的输出(RSC payload)作为 children 传递给客户端组件。客户端组件永远不需要知道它们是服务端组件——它只是接收预渲染的 React 节点。

children 想象成客户端组件中的一个"孔洞",服务端渲染的内容可以通过它流过。

将服务端组件作为 Props 传递#

children 模式可以推广到任何接受 React.ReactNode 的 prop:

tsx
// 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>
  );
}
tsx
// 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 的。这非常重要,因为它意味着数据获取发生在渲染期间,而不是作为副作用:

tsx
// 这是合法且优雅的
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 边界,整个页面会等待最慢的异步组件。有了它们,每个部分独立流式传输:

tsx
// 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 边界也是合法且有用的:

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard>
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
    <Suspense fallback={<TableSkeleton />}>
      <RecentTransactions />
    </Suspense>
  </Dashboard>
</Suspense>

如果 Dashboard 很快但 RevenueChart 很慢,外层 Suspense 先解析(显示仪表盘外壳),内层图表的 Suspense 稍后解析。

Error Boundary 与 Suspense#

Suspenseerror.tsx 配对实现弹性 UI:

tsx
// 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

tsx
"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

useStateuseEffectuseReduceruseRef(用于可变 ref)、useContext —— 任何这些都需要 "use client"

tsx
"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. 它附加了事件处理器

onClickonChangeonSubmitonMouseEnter —— 任何交互行为都意味着客户端。

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(客户端)— 带验证的表单

注意这个模式:页面外壳和数据密集的部分是服务端组件。交互岛屿(ImageGalleryAddToCartButtonReviewForm)是客户端组件。慢的部分(RelatedProductsProductReviews)被包裹在 Suspense 中。

这不是理论。这就是我的组件树实际的样子。

常见错误(我全都犯过)#

错误 1:把所有东西都变成客户端组件#

从 Pages Router 或 Create React App 迁移时,阻力最小的方式是在所有东西上加 "use client"。它能工作!什么都不会坏!但你也把整个组件树作为 JavaScript 发送了出去,得到了零 RSC 收益。

我见过根布局上有 "use client" 的代码库。到了那个程度,你实际上就是在多走了几步的情况下运行一个客户端 React 应用。

修复:从服务端组件开始。只在编译器告诉你需要时(因为你使用了 hook 或事件处理器)添加 "use client"。尽可能将 "use client" 推到树的叶子节点。

错误 2:通过边界层层传递 Props#

tsx
// 差:在服务端组件中获取数据,然后通过多个客户端组件传递
// 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 传递:

tsx
// 好:服务端组件获取自己的数据,作为 children 传递
// app/page.tsx(服务端)
export default function Page() {
  return (
    <ClientShell>
      <UserInfo />      {/* 服务端组件 — 获取自己的数据 */}
      <Settings />      {/* 服务端组件 — 获取自己的数据 */}
      <ClientWidget>
        <UserAvatar />  {/* 服务端组件 — 获取自己的数据 */}
      </ClientWidget>
    </ClientShell>
  );
}

错误 3:不使用 Suspense#

没有 Suspense,你的页面的 TTFB 受限于最慢的数据获取。我有一个仪表盘页面加载需要 4 秒,因为一个分析查询很慢,尽管页面其余数据在 200ms 内就准备好了。

tsx
// 差:所有东西都等待所有东西
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>
  );
}
tsx
// 好: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 模型结果(转换为 numberstring
tsx
// 差
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() 函数:

tsx
// 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——不是根级别:

tsx
// 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;
}
tsx
// 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 输出到客户端。

tsx
// 服务端组件 — 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() 用于按请求去重,不是持久缓存:

tsx
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",但它在生产中工作得很好):

tsx
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} />;
}

要使缓存失效:

tsx
"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/serverconnection()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. 路由器缓存(客户端)    — 浏览器中已访问路由的缓存

每一层都有不同的用途。你不一定需要所有层,但理解哪一层是活跃的有助于调试"为什么我的数据没有更新?"的问题。

实际的缓存策略#

这是我在生产中实际做的:

tsx
// 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 路由就能与服务器通信的方式。

tsx
// 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" };
  }
}
tsx
// 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 服务端组件中。这是真正的迁移工作所在。

之前:

tsx
// 旧模式 — 在客户端组件中获取数据
"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>
  );
}

之后:

tsx
// 新模式 — 服务端组件获取,客户端组件交互
 
// 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:在交互边界拆分组件#

许多组件大部分是静态的,只有一小部分是交互式的。拆分它们:

之前(一个大的客户端组件):

tsx
"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>
  );
}

之后(服务端组件加一个小的客户端岛屿):

tsx
// 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>
  );
}
tsx
// 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:

之前:

tsx
// 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),
});

之后:

tsx
// 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 组件#

测试服务端组件需要稍微不同的方法,因为它们可以是异步的:

tsx
// __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 进行组合。

这就是整个策略手册。其他都是细节。

相关文章