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週)

逐頁遷移:

  1. src/pages/ 搬到 src/app/
  2. getServerSideProps 替換為 async Server Component
  3. getStaticProps + getStaticPaths 替換為 generateStaticParams
  4. 只在需要互動的地方加 "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 大小縮減

遷移坑點

  1. next/routernext/navigationuseRouter 現在從 next/navigation 導入,API 不一樣
  2. next/head → Metadata API:靜態 <Head> 標籤改為匯出 generateMetadata()
  3. _app.tsxlayout.tsx:Layout 是巢狀而非包裹;導航間狀態可以持久保留
  4. 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_cacherevalidateTag 做激進快取。遷移前後都要測量。效能收益是實打實的,但需要紀律。


線上工具

  • JSON 格式化 — 格式化和校驗 Server/Client Component 之間交換的 JSON 資料
  • Base64 編解碼 — 處理跨 RSC 序列化邊界傳遞的二進位資料
  • 程式碼截圖 — 截取和分享你的 RSC 組件程式碼

本站提供瀏覽器本地工具,免註冊即可試用 →

#React#Server Components#RSC#Next.js#SSR#前端工程#2026