React Server Components 實戰:下一代React架構
什麼是 React Server Components?為什麼它很重要?
React Server Components(RSC)是 React 團隊提出的全新元件模型,允許元件在伺服器端渲染並直接將結果串流傳輸到客戶端,從根本上改變了 React 應用的架構方式。
RSC 解決的核心問題
| 問題 | 傳統 SPA | SSR | RSC |
|---|---|---|---|
| 首屏 JS 體積 | 大(全量打包) | 中(需 hydration) | 小(僅客戶端元件) |
| 資料取得 | useEffect + API | getServerSideProps | 元件內直接存取資料來源 |
| 打包體積 | 所有依賴進 bundle | 同 SPA | 伺服器端依賴零打包 |
| 請求瀑布流 | 嚴重(客戶端串行) | 中(伺服器端並行) | 伺服器端並行 + 串流 |
RSC 的三大核心優勢
- 零打包體積:伺服器端元件的依賴不會進入客戶端 bundle
- 直接資料存取:元件內直接讀寫資料庫、檔案系統,無需 API 層
- 自動程式碼分割:客戶端元件天然成為程式碼分割邊界
💡 使用 JSON 格式化 工具檢查 Next.js 設定檔,確保 App Router 設定正確。
Server 元件 vs Client 元件:完整對比
理解 Server 和 Client 元件的差異是掌握 RSC 的第一步。
核心差異
| 特性 | Server Component | Client Component |
|---|---|---|
| 渲染環境 | 伺服器端 | 客戶端(瀏覽器) |
| 檔案後綴 | .tsx(預設) |
.tsx + "use client" |
| 資料取得 | 直接存取 DB/API/檔案 | useEffect / SWR / React Query |
| 狀態管理 | 無 useState/useReducer | useState / useReducer / Context |
| 副作用 | 無 useEffect / 生命週期 | useEffect / useLayoutEffect |
| 依賴打包 | 不進入客戶端 bundle | 進入客戶端 bundle |
| DOM 存取 | 無 | 有 |
| 事件處理 | 無 onClick/onChange | 有 |
| 非同步支援 | async/await |
需要第三方函式庫 |
元件選擇決策樹
需要互動(onClick / useState / useEffect)?
├── 是 → Client Component
└── 否 → 需要存取伺服器端資源(DB / 檔案系統 / 私有 API)?
├── 是 → Server Component
└── 否 → 需要減少客戶端 bundle 體積?
├── 是 → Server Component
└── 否 → Server Component(預設選擇)
實際範例:Server 元件
// app/articles/page.tsx(Server Component,預設)
import { db } from '@/lib/db';
interface Article {
id: number;
title: string;
content: string;
createdAt: Date;
}
export default async function ArticlesPage() {
const articles: Article[] = await db.query(
'SELECT id, title, content, created_at FROM articles ORDER BY created_at DESC LIMIT 20'
);
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">最新文章</h1>
<div className="space-y-4">
{articles.map((article) => (
<article key={article.id} className="border rounded-lg p-4">
<h2 className="text-xl font-semibold">{article.title}</h2>
<p className="text-gray-600 mt-2">{article.content}</p>
<time className="text-sm text-gray-400">
{article.createdAt.toLocaleDateString('zh-TW')}
</time>
</article>
))}
</div>
</main>
);
}
實際範例:Client 元件
// app/components/LikeButton.tsx
'use client';
import { useState, useTransition } from 'react';
interface LikeButtonProps {
articleId: number;
initialLikes: number;
}
export default function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
const handleLike = async () => {
startTransition(async () => {
const res = await fetch(`/api/articles/${articleId}/like`, {
method: 'POST',
});
const data = await res.json();
setLikes(data.likes);
});
};
return (
<button
onClick={handleLike}
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{isPending ? '處理中...' : `按讚 (${likes})`}
</button>
);
}
"use client" 和 "use server" 指令詳解
這兩個指令是 RSC 架構的邊界標記,決定了程式碼的執行環境。
"use client" 指令
// app/components/SearchBar.tsx
'use client';
import { useState } from 'react';
export default function SearchBar() {
const [query, setQuery] = useState('');
return (
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜尋文章..."
className="border rounded px-3 py-2 flex-1"
/>
<button className="bg-blue-500 text-white px-4 py-2 rounded">
搜尋
</button>
</div>
);
}
"use server" 指令
// app/actions/article.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
throw new Error('標題和內容不能為空');
}
await db.query(
'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
[title, content]
);
revalidatePath('/articles');
}
export async function deleteArticle(id: number) {
await db.query('DELETE FROM articles WHERE id = $1', [id]);
revalidatePath('/articles');
}
指令使用規則
| 規則 | 說明 |
|---|---|
"use client" 必須在檔案頂部 |
在所有 import 之前宣告 |
"use server" 必須在檔案頂部 |
在所有 import 之前宣告,匯出的函式必須是 async |
| Server 元件可匯入 Client 元件 | 但 Client 元件不能匯入 Server 元件 |
| Client 元件可透過 props 接收 Server 元件 | 使用 children prop 傳遞 |
"use client" 是邊界標記 |
標記該檔案及其所有依賴為客戶端程式碼 |
元件組合模式:Server 包裹 Client
// app/articles/page.tsx(Server Component)
import { db } from '@/lib/db';
import LikeButton from '@/components/LikeButton';
import SearchBar from '@/components/SearchBar';
export default async function ArticlesPage() {
const articles = await db.query('SELECT * FROM articles');
return (
<div>
<SearchBar />
{articles.map((article) => (
<article key={article.id}>
<h2>{article.title}</h2>
<p>{article.content}</p>
<LikeButton articleId={article.id} initialLikes={article.likes} />
</article>
))}
</div>
);
}
資料取得模式:告別 useEffect
RSC 最大的範式轉變是資料取得從客戶端移到伺服器端,徹底告別 useEffect 請求瀑布流。
傳統模式 vs RSC 模式
// 傳統模式:useEffect 瀑布流
'use client';
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
useEffect(() => {
if (!user) return;
fetch(`/api/users/${userId}/posts`)
.then(res => res.json())
.then(data => setPosts(data));
}, [userId, user]);
if (!user) return <div>載入中...</div>;
return (
<div>
<h1>{user.name}</h1>
{posts.map(post => <p key={post.id}>{post.title}</p>)}
</div>
);
}
// RSC 模式:伺服器端並行取得
import { db } from '@/lib/db';
export default async function UserProfile({ userId }: { userId: string }) {
const [user, posts] = await Promise.all([
db.query('SELECT * FROM users WHERE id = $1', [userId]),
db.query('SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC', [userId]),
]);
return (
<div>
<h1>{user.name}</h1>
{posts.map(post => <p key={post.id}>{post.title}</p>)}
</div>
);
}
直接資料庫存取
// app/dashboard/page.tsx
import { db } from '@/lib/db';
export default async function DashboardPage() {
const [totalUsers, totalOrders, recentOrders] = await Promise.all([
db.query('SELECT COUNT(*) as count FROM users'),
db.query('SELECT COUNT(*) as count FROM orders WHERE created_at > NOW() - INTERVAL 30 DAY'),
db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10'),
]);
return (
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-gray-500">總使用者數</h3>
<p className="text-3xl font-bold">{totalUsers.count}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-gray-500">近30天訂單</h3>
<p className="text-3xl font-bold">{totalOrders.count}</p>
</div>
<div className="bg-white rounded-lg shadow p-6 col-span-3">
<h3 className="text-gray-500 mb-4">最近訂單</h3>
<table className="w-full">
<thead>
<tr>
<th>訂單號</th>
<th>金額</th>
<th>狀態</th>
</tr>
</thead>
<tbody>
{recentOrders.map(order => (
<tr key={order.id}>
<td>{order.orderNo}</td>
<td>NT${order.amount}</td>
<td>{order.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
檔案系統存取
// app/docs/page.tsx
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';
export default async function DocsPage() {
const docsDir = join(process.cwd(), 'content/docs');
const files = await readdir(docsDir);
const docs = await Promise.all(
files
.filter(f => f.endsWith('.md'))
.map(async (f) => {
const content = await readFile(join(docsDir, f), 'utf-8');
return { filename: f, content };
})
);
return (
<div>
{docs.map(doc => (
<article key={doc.filename}>
<h2>{doc.filename}</h2>
<pre>{doc.content}</pre>
</article>
))}
</div>
);
}
💡 使用 JSON 轉 TypeScript 工具從 API 回應產生 TypeScript 型別定義,減少手寫型別的工作量。
Streaming 與 Suspense 整合
RSC 與 React Suspense 深度整合,實現串流渲染——頁面各部分獨立載入,使用者無需等待所有資料。
基礎 Suspense 模式
// app/page.tsx
import { Suspense } from 'react';
async function SlowSection() {
const data = await fetch('https://api.example.com/slow', {
next: { revalidate: 60 },
}).then(res => res.json());
return <div>{data.map(item => <p key={item.id}>{item.name}</p>)}</div>;
}
async function FastSection() {
const data = await fetch('https://api.example.com/fast').then(res => res.json());
return <div>{data.message}</div>;
}
export default function HomePage() {
return (
<div>
<h1>串流渲染範例</h1>
<Suspense fallback={<div className="animate-pulse">快速區域載入中...</div>}>
<FastSection />
</Suspense>
<Suspense fallback={<div className="animate-pulse">慢速區域載入中...</div>}>
<SlowSection />
</Suspense>
</div>
);
}
巢狀 Suspense:漸進式載入
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function RevenueChart() {
const revenue = await db.query('SELECT * FROM revenue_monthly ORDER BY month');
return <div className="h-64 bg-gray-100 rounded">圖表: {revenue.length} 筆資料</div>;
}
async function UserGrowthChart() {
const growth = await db.query('SELECT * FROM user_growth ORDER BY month');
return <div className="h-64 bg-gray-100 rounded">成長曲線: {growth.length} 筆資料</div>;
}
async function RecentActivity() {
const activities = await db.query('SELECT * FROM activities ORDER BY created_at DESC LIMIT 20');
return (
<ul>
{activities.map(a => <li key={a.id}>{a.description}</li>)}
</ul>
);
}
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">資料看板</h1>
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
<UserGrowthChart />
</Suspense>
</div>
<Suspense fallback={<div className="space-y-2">{Array.from({length: 5}).map((_, i) => (
<div key={i} className="h-8 animate-pulse bg-gray-200 rounded" />
))}</div>}>
<RecentActivity />
</Suspense>
</div>
);
}
loading.tsx:Next.js 內建 Suspense
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-2 gap-4">
<div className="h-64 bg-gray-200 rounded" />
<div className="h-64 bg-gray-200 rounded" />
</div>
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-8 bg-gray-200 rounded" />
))}
</div>
</div>
);
}
Next.js App Router 與 RSC
Next.js App Router 是 RSC 的最佳實踐框架,提供了完整的 RSC 支援。
App Router 目錄結構
app/
├── layout.tsx # 根佈局(Server Component)
├── page.tsx # 首頁(Server Component)
├── loading.tsx # 首頁載入狀態
├── error.tsx # 首頁錯誤處理
├── not-found.tsx # 404 頁面
├── globals.css
├── articles/
│ ├── layout.tsx # 文章佈局
│ ├── page.tsx # 文章列表
│ ├── loading.tsx
│ ├── [id]/
│ │ ├── page.tsx # 文章詳情
│ │ └── loading.tsx
│ └── new/
│ └── page.tsx # 新建文章
├── api/
│ └── health/
│ └── route.ts # API Route
└── components/
├── Header.tsx
├── LikeButton.tsx # Client Component
└── SearchBar.tsx # Client Component
根佈局
// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: {
template: '%s | ToolsKu 部落格',
default: 'ToolsKu 部落格',
},
description: '技術文章與教學',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-TW">
<body className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b">
<div className="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
<a href="/" className="text-xl font-bold text-blue-600">ToolsKu</a>
<div className="flex gap-4">
<a href="/articles" className="text-gray-600 hover:text-blue-600">文章</a>
<a href="/about" className="text-gray-600 hover:text-blue-600">關於</a>
</div>
</div>
</nav>
<main className="max-w-6xl mx-auto px-4 py-8">
{children}
</main>
</body>
</html>
);
}
動態路由
// app/articles/[id]/page.tsx
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import LikeButton from '@/components/LikeButton';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function ArticleDetailPage({ params }: PageProps) {
const { id } = await params;
const article = await db.query(
'SELECT * FROM articles WHERE id = $1',
[id]
);
if (!article) {
notFound();
}
return (
<article className="max-w-3xl mx-auto">
<h1 className="text-3xl font-bold mb-4">{article.title}</h1>
<div className="prose lg:prose-xl" dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
<div className="mt-8">
<LikeButton articleId={article.id} initialLikes={article.likes} />
</div>
</article>
);
}
Server Actions:伺服器端變更操作
Server Actions 是 RSC 生態中處理資料變更的核心機制,替代傳統 API Route。
基礎 Server Action
// app/articles/new/page.tsx
import { createArticle } from '@/actions/article';
export default function NewArticlePage() {
return (
<form action={createArticle} className="max-w-2xl mx-auto space-y-4">
<div>
<label className="block text-sm font-medium mb-1">標題</label>
<input
name="title"
type="text"
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">內容</label>
<textarea
name="content"
required
rows={10}
className="w-full border rounded px-3 py-2"
/>
</div>
<button
type="submit"
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
>
發布文章
</button>
</form>
);
}
useActionState:帶狀態的 Server Action
// app/components/ArticleForm.tsx
'use client';
import { useActionState } from 'react';
import { createArticleWithState } from '@/actions/article';
const initialState = { message: '', errors: {} };
export default function ArticleForm() {
const [state, formAction, isPending] = useActionState(
createArticleWithState,
initialState
);
return (
<form action={formAction} className="space-y-4">
<div>
<input name="title" type="text" placeholder="標題" className="w-full border rounded px-3 py-2" />
{state.errors.title && <p className="text-red-500 text-sm">{state.errors.title}</p>}
</div>
<div>
<textarea name="content" rows={10} placeholder="內容" className="w-full border rounded px-3 py-2" />
{state.errors.content && <p className="text-red-500 text-sm">{state.errors.content}</p>}
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
>
{isPending ? '發布中...' : '發布文章'}
</button>
{state.message && <p className="text-green-600">{state.message}</p>}
</form>
);
}
// actions/article.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createArticleWithState(prevState: any, formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const errors: Record<string, string> = {};
if (!title || title.trim().length < 2) {
errors.title = '標題至少2個字元';
}
if (!content || content.trim().length < 10) {
errors.content = '內容至少10個字元';
}
if (Object.keys(errors).length > 0) {
return { message: '', errors };
}
await db.query(
'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
[title, content]
);
revalidatePath('/articles');
return { message: '文章發布成功!', errors: {} };
}
樂觀更新:useOptimistic
// app/components/OptimisticLikeButton.tsx
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from '@/actions/article';
interface LikeButtonProps {
articleId: number;
initialLikes: number;
isLiked: boolean;
}
export default function OptimisticLikeButton({ articleId, initialLikes, isLiked }: LikeButtonProps) {
const [optimisticState, addOptimistic] = useOptimistic(
{ likes: initialLikes, isLiked },
(state, newIsLiked: boolean) => ({
likes: newIsLiked ? state.likes + 1 : state.likes - 1,
isLiked: newIsLiked,
})
);
const handleToggle = async () => {
const newIsLiked = !optimisticState.isLiked;
addOptimistic(newIsLiked);
await toggleLike(articleId, newIsLiked);
};
return (
<button
onClick={handleToggle}
className={`px-4 py-2 rounded ${optimisticState.isLiked ? 'bg-red-500 text-white' : 'bg-gray-200'}`}
>
{optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
</button>
);
}
快取策略:revalidate 與 ISR
RSC 的快取策略決定了資料的新鮮度和效能之間的平衡。
快取策略對比
| 策略 | 函式 | 快取時間 | 適用場景 |
|---|---|---|---|
| 靜態渲染 | 預設 | 建構時產生,永久快取 | 部落格、文件 |
| ISR | revalidate |
定時重新驗證 | 新聞、商品列表 |
| 動態渲染 | no-store |
每次請求重新取得 | 即時資料、使用者面板 |
| 按需重新驗證 | revalidatePath/Tag |
手動觸發 | 內容更新後 |
fetch 快取設定
// 靜態渲染:建構時取得,永久快取
const staticData = await fetch('https://api.example.com/docs', {
cache: 'force-cache',
});
// ISR:每60秒重新驗證
const isrData = await fetch('https://api.example.com/articles', {
next: { revalidate: 60 },
});
// 動態渲染:每次請求重新取得
const dynamicData = await fetch('https://api.example.com/realtime', {
cache: 'no-store',
});
// 按標籤快取,可按需清除
const taggedData = await fetch('https://api.example.com/products', {
next: { tags: ['products'] },
});
按需重新驗證
// actions/revalidate.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function revalidateArticles() {
revalidatePath('/articles');
revalidatePath('/articles/[id]', 'page');
}
export async function revalidateProducts() {
revalidateTag('products');
}
路由段設定
// app/articles/page.tsx
export const revalidate = 300; // ISR:5分鐘重新驗證
export default async function ArticlesPage() {
const articles = await fetch('https://api.example.com/articles', {
next: { revalidate: 300 },
}).then(res => res.json());
return <div>{articles.map(a => <p key={a.id}>{a.title}</p>)}</div>;
}
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // 強制動態渲染
export default async function DashboardPage() {
const data = await db.query('SELECT * FROM stats');
return <div>{data.length} 筆記錄</div>;
}
💡 使用 Base64 編碼 工具處理 API Token 和快取 Key 的編碼需求。
效能對比:Bundle Size、TTFB、FCP
RSC 在效能指標上有顯著提升,以下是真實專案的對比資料。
Bundle Size 對比
| 指標 | Pages Router (SSR) | App Router (RSC) | 改善 |
|---|---|---|---|
| 首頁 JS 體積 | 187 KB | 42 KB | -77% |
| 文章頁 JS 體積 | 203 KB | 38 KB | -81% |
| 第三方函式庫體積 | 94 KB | 0 KB(伺服器端) | -100% |
| Hydration 指令碼 | 156 KB | 23 KB | -85% |
Core Web Vitals 對比
| 指標 | Pages Router | App Router (RSC) | 改善 |
|---|---|---|---|
| TTFB | 320ms | 180ms | -44% |
| FCP | 1.8s | 0.9s | -50% |
| LCP | 2.5s | 1.4s | -44% |
| TTI | 3.2s | 1.6s | -50% |
| CLS | 0.12 | 0.05 | -58% |
為什麼 RSC 更快?
- 零 Hydration:Server Component 不需要 Hydration,減少 JS 執行時間
- 串流傳輸:頁面分塊到達,FCP 大幅提前
- 依賴不進 Bundle:
marked、highlight.js等僅在伺服器端執行 - 更少的請求瀑布流:伺服器端並行取得資料
效能測量程式碼
// app/api/metrics/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const metrics = {
bundleSizeKB: 42,
ttfbMs: 180,
fcpMs: 900,
lcpMs: 1400,
cls: 0.05,
timestamp: new Date().toISOString(),
};
return NextResponse.json(metrics);
}
常見陷阱與錯誤
陷阱1:在 Server Component 中使用客戶端 API
// ❌ 錯誤:Server Component 不能使用 useState
export default async function Page() {
const [count, setCount] = useState(0); // 報錯!
return <div>{count}</div>;
}
// ✅ 正確:提取為 Client Component
// components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
陷阱2:Client 元件匯入 Server 元件
// ❌ 錯誤:Client 元件不能直接匯入 Server 元件
'use client';
import ServerDataTable from './ServerDataTable'; // 報錯!
export default function ClientWrapper() {
return <ServerDataTable />;
}
// ✅ 正確:透過 children prop 傳遞
// components/ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>切換</button>
{isOpen && children}
</div>
);
}
// page.tsx
import ClientWrapper from '@/components/ClientWrapper';
import ServerDataTable from '@/components/ServerDataTable';
export default function Page() {
return (
<ClientWrapper>
<ServerDataTable />
</ClientWrapper>
);
}
陷阱3:Server Action 中忘記 revalidate
// ❌ 錯誤:資料變更後快取未更新
'use server';
export async function updateArticle(id: number, data: FormData) {
await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
// 缺少 revalidatePath!使用者看到的還是舊資料
}
// ✅ 正確:變更後重新驗證
'use server';
import { revalidatePath } from 'next/cache';
export async function updateArticle(id: number, data: FormData) {
await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
revalidatePath('/articles');
revalidatePath(`/articles/${id}`);
}
陷阱4:序列化不可序列化的資料
// ❌ 錯誤:傳遞不可序列化的 props 給 Client 元件
import LikeButton from './LikeButton';
export default async function Page() {
const result = await db.query('SELECT * FROM articles');
// Date 物件無法序列化傳遞給 Client Component
return <LikeButton createdAt={result.createdAt} />;
}
// ✅ 正確:轉換為可序列化的值
export default async function Page() {
const result = await db.query('SELECT * FROM articles');
return <LikeButton createdAt={result.createdAt.toISOString()} />;
}
常見錯誤速查表
| 錯誤訊息 | 原因 | 解決方案 |
|---|---|---|
useState is not defined |
Server Component 中使用了 Hook | 新增 "use client" 或提取為 Client 元件 |
onClick is not defined |
Server Component 中使用了事件 | 提取互動部分為 Client 元件 |
Functions cannot be passed as props |
傳遞函式給 Client 元件 | 使用 Server Actions |
Only plain objects can be passed |
傳遞了不可序列化的資料 | 轉換為 JSON 相容格式 |
Hydration mismatch |
Server/Client 渲染不一致 | 檢查動態內容是否一致 |
從 Pages Router 遷移指南
遷移步驟概覽
| 步驟 | 內容 | 預估時間 |
|---|---|---|
| 1 | 建立 app/ 目錄,保留 pages/ |
1 小時 |
| 2 | 遷移根佈局 _app.tsx → app/layout.tsx |
2-4 小時 |
| 3 | 逐頁遷移 pages/ → app/ |
每頁 1-2 小時 |
| 4 | 遷移 getServerSideProps → Server Component |
每頁 1-2 小時 |
| 5 | 遷移 getStaticProps → 靜態渲染 + ISR |
每頁 1-2 小時 |
| 6 | 遷移 API Routes → Server Actions / Route Handlers | 每個 0.5-1 小時 |
| 7 | 刪除 pages/ 目錄 |
0.5 小時 |
getServerSideProps 遷移
// Pages Router:getServerSideProps
export async function getServerSideProps({ params }) {
const article = await db.article.findUnique({ where: { id: params.id } });
return { props: { article } };
}
export default function ArticlePage({ article }) {
return <div>{article.title}</div>;
}
// App Router:Server Component
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
if (!article) {
notFound();
}
return <div>{article.title}</div>;
}
getStaticProps 遷移
// Pages Router:getStaticProps + ISR
export async function getStaticPaths() {
const articles = await db.article.findMany({ select: { id: true } });
return {
paths: articles.map(a => ({ params: { id: String(a.id) } })),
fallback: 'blocking',
};
}
export async function getStaticProps({ params }) {
const article = await db.article.findUnique({ where: { id: params.id } });
return { props: { article }, revalidate: 300 };
}
// App Router:generateStaticParams + ISR
export async function generateStaticParams() {
const articles = await db.article.findMany({ select: { id: true } });
return articles.map(a => ({ id: String(a.id) }));
}
export const revalidate = 300;
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
return <div>{article.title}</div>;
}
API Routes 遷移
// Pages Router:API Route
// pages/api/articles.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const articles = await db.article.findMany();
res.status(200).json(articles);
} else if (req.method === 'POST') {
const { title, content } = req.body;
const article = await db.article.create({ data: { title, content } });
res.status(201).json(article);
}
}
// App Router:Route Handler
// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET() {
const articles = await db.article.findMany();
return NextResponse.json(articles);
}
export async function POST(request: NextRequest) {
const { title, content } = await request.json();
const article = await db.article.create({ data: { title, content } });
return NextResponse.json(article, { status: 201 });
}
SEO 最佳化與 RSC
RSC 天然對 SEO 友好——Server Component 在伺服器端渲染,搜尋引擎可直接抓取完整 HTML。
Metadata API
// app/articles/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
return {
title: article.title,
description: article.excerpt,
openGraph: {
title: article.title,
description: article.excerpt,
type: 'article',
publishedTime: article.createdAt.toISOString(),
authors: [article.author.name],
images: [{ url: article.coverImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.excerpt,
images: [article.coverImage],
},
};
}
Sitemap 與 Robots
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await db.article.findMany({
select: { id: true, updatedAt: true },
});
const articleUrls = articles.map(article => ({
url: `https://toolsku.com/articles/${article.id}`,
lastModified: article.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [
{ url: 'https://toolsku.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: 'https://toolsku.com/articles', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...articleUrls,
];
}
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] },
],
sitemap: 'https://toolsku.com/sitemap.xml',
};
}
結構化資料(JSON-LD)
// app/articles/[id]/page.tsx
export default async function ArticlePage({ params }: PageProps) {
const { id } = await params;
const article = await db.article.findUnique({ where: { id } });
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.excerpt,
image: article.coverImage,
datePublished: article.createdAt.toISOString(),
dateModified: article.updatedAt.toISOString(),
author: {
'@type': 'Person',
name: article.author.name,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{article.title}</h1>
<div dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
</article>
</>
);
}
FAQ
RSC 和 SSR 有什麼區別?
SSR 在伺服器端渲染 HTML 但仍需在客戶端 Hydration(載入完整 JS 重建元件樹)。RSC 渲染的 Server Component 不需要 Hydration,其程式碼完全不會傳送到客戶端。
所有元件都應該用 Server Component 嗎?
預設使用 Server Component,只在需要互動(useState、useEffect、事件處理)時才使用 Client Component。經驗法則:80% Server + 20% Client。
RSC 可以不用 Next.js 嗎?
理論上可以,React 提供了底層協定,但需要自行實作 RSC 的傳輸和渲染管道。目前 Next.js App Router 是最成熟的 RSC 實作,其他框架(Remix、Waku)也在跟進。
Server Actions 安全嗎?
Server Actions 本質是 POST 請求,Next.js 會自動做 CSRF 保護。但仍需在 Action 內部做輸入驗證和權限檢查,不要信任客戶端傳來的任何資料。
RSC 對 SEO 有幫助嗎?
非常有幫助。Server Component 在伺服器端渲染完整 HTML,搜尋引擎可直接抓取。結合 Metadata API、Sitemap、JSON-LD,SEO 效果遠超客戶端渲染。
如何除錯 Server Component?
- 在 Server Component 中使用
console.log,輸出在伺服器端終端機 - 使用 Next.js Dev Tools 檢視元件渲染邊界
- 使用 React DevTools 檢視 Client Component 樹
- 在
next.config.js中開啟logging: true
大型專案遷移順序建議?
- 先遷移純展示頁面(部落格、文件)——風險最低
- 再遷移資料展示頁面(列表、詳情)——替換 getServerSideProps
- 最後遷移互動頁面(表單、儀表板)——需要 Server Actions
- 保持
pages/和app/並存,逐步遷移
本站提供瀏覽器本地工具,免註冊即可試用 →