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 組件程式碼
本站提供瀏覽器本地工具,免註冊即可試用 →