Server Components 和 Client Components
Server Components:組件代碼在伺服器執行,永遠不會被傳到瀏覽器。你可以直接查資料庫、訪問環境變數、執行任何 Node.js 操作。最後伺服器把組件生成的資料發送給客戶端。
import { db } from '@/lib/database'
export default async function ProductsPage() {
const products = await db.products.findAll()
return <div>{products.map(p => p.name)}</div>
}Client Components:組件代碼會被發送到瀏覽器執行。這就是傳統的 React 組件,可以用 Hook、事件、state。加上 'use client' 指令就變成 Client Component。
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>
}關鍵差異:Server Component 無法互動(沒有 Hook、沒有事件),Client Component 可以互動但看不到資料庫。
場景 | 用什麼 | 為什麼 |
|---|
查資料庫、拿環境變數 | Server Component | 直接 |
列表頁、詳情頁 | Server Component | 資料已經準備好,不需要互動 |
點擊按鈕、輸入表單 | Client Component | 需要實時反應 |
modal 彈窗 | Client Component | 需要 state 控制開關 |
路由導航 | Client Component | 需要交互 |
複雜的狀態邏輯 | Client Component | useReducer、Context 都在這裡 |
實例示範 1:電商產品詳情頁
假設你在做一個電商網站的產品詳情頁,頁面需要展示產品資訊、評論、相關推薦,還要有「加入購物車」按鈕,這樣要怎麼設計?
Server Component:頁面本身
import { db } from '@/lib/database'
import { ProductDetail } from './ProductDetail'
import { AddToCartButton } from './AddToCartButton'
import { ReviewSection } from './ReviewSection'
import { RelatedProducts } from './RelatedProducts'
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.products.findById(params.id)
const reviews = await db.reviews.findByProductId(params.id)
const relatedProducts = await db.products.findRelated(product.categoryId)
return (
<div>
<ProductDetail product={product} />
<AddToCartButton productId={product.id} /> {}
<ReviewSection reviews={reviews} productId={product.id} /> {}
<RelatedProducts products={relatedProducts} />
</div>
)
}為什麼頁面本身是 Server Component?因為它需要做幾件事:
查資料庫拿產品資訊 - 只有伺服器端可以安全地做到,credentials 不會洩露
複雜的資料庫查詢 - findRelated 可能涉及複雜的 SQL join,直接在伺服器執行更簡單
SEO - 搜索引擎爬蟲看到完整的 HTML,包括產品資訊和評論
這樣做有甚麼好處?如果整個頁面都是 Client Component,你需要傳上百 KB 的 JavaScript 到瀏覽器(React、狀態管理、API 客戶端等)。用戶要等 2-3 秒才能看到產品資訊。
用 Server Component,產品資訊已經在 HTML 裡了,只要用戶打開頁面 0.5 秒就能看到,然後 JavaScript 還也許只有 30KB(只是加入購物車和新增評論的邏輯)。
Client Component:加入購物車按鈕
'use client'
import { useState } from 'react'
export function AddToCartButton({ productId }: { productId: string }) {
const [quantity, setQuantity] = useState(1)
const [loading, setLoading] = useState(false)
const handleAddToCart = async () => {
setLoading(true)
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId, quantity })
})
setLoading(false)
}
return (
<div>
<input
type="number"
value={quantity}
onChange={e => setQuantity(+e.target.value)}
min="1"
/>
<button onClick={handleAddToCart} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</button>
</div>
)
}為什麼加入購物車是 Client Component?因為它需要:
State 管理 - 數量的增減需要 useState
事件處理 - 點擊按鈕的反應
Loading 狀態 - 用戶提交後要顯示「Adding...」
Server Component 沒有這些能力,所以必須用 Client Component。
評論區,混用是個不錯的選項
import { ReviewList } from './ReviewList'
import { AddReviewForm } from './AddReviewForm'
export function ReviewSection({ reviews, productId }: any) {
return (
<div>
<h2>Reviews ({reviews.length})</h2>
<ReviewList reviews={reviews} /> {}
<AddReviewForm productId={productId} /> {}
</div>
)
}評論列表由 Server Component 展示(因為資料已經準備好了),新增評論由 Client Component 處理(因為需要表單互動)。
'use client'
import { useState } from 'react'
export function AddReviewForm({ productId }: { productId: string }) {
const [rating, setRating] = useState(5)
const [text, setText] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async () => {
setSubmitting(true)
await fetch('/api/reviews', {
method: 'POST',
body: JSON.stringify({ productId, rating, text })
})
setText('')
setSubmitting(false)
}
return (
<form onSubmit={e => { e.preventDefault(); handleSubmit() }}>
<select value={rating} onChange={e => setRating(+e.target.value)}>
<option value="1">1 Star</option>
<option value="5">5 Stars</option>
</select>
<textarea
value={text}
onChange={e => setText(e.target.value)}
placeholder="Your review..."
/>
<button type="submit" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Review'}
</button>
</form>
)
}這個設計解決了什麼問題?
載入速度快 - 產品資訊馬上展示,不用等 JavaScript
資料庫安全 - credentials 永遠在伺服器,不會洩露給瀏覽器
開發簡單 - 不需要複雜的狀態管理來同步伺服器資料
SEO 友善 - 爬蟲看到完整的 HTML
實例 2:POS 系統庫存頁面
POS 系統更複雜,因為需要實時同步。而且有可能出現多個收銀台同時在操作,必須確保庫存資料一致。
Server Component:初始化頁面
import { db } from '@/lib/database'
import { InventoryTable } from './InventoryTable'
import { AddProductForm } from './AddProductForm'
export default async function InventoryPage() {
const products = await db.inventory.getAllProducts()
const totalValue = await db.inventory.calculateTotalValue()
return (
<div>
<h1>Inventory Management</h1>
<p>Total Value: ${totalValue}</p>
<AddProductForm /> {}
<InventoryTable initialProducts={products} /> {}
</div>
)
}為什麼要用 Server Component 初始化?
多租戶隔離 - Server Component 可以在伺服器端驗證租戶身份,確保店家 A 看不到店家 B 的庫存
複雜業務邏輯 - 檢查「庫存低於 10 時自動訂購」這種規則,應該在伺服器端執行,而不是客戶端(否則聰明的用戶可能改 JavaScript 破壞規則)
初始化速度 - 用戶早上 7 點打開系統時,庫存資料已經準備好了
Client Component:實時監聽更新
'use client'
import { useEffect, useState } from 'react'
export function InventoryTable({ initialProducts }: { initialProducts: any[] }) {
const [products, setProducts] = useState(initialProducts)
const [connection, setConnection] = useState<any>(null)
useEffect(() => {
const conn = new WebSocket('ws://localhost:8000/ws/inventory')
conn.onmessage = (event) => {
const { type, product } = JSON.parse(event.data)
if (type === 'INVENTORY_UPDATED') {
setProducts(prev =>
prev.map(p => p.id === product.id ? product : p)
)
}
}
setConnection(conn)
return () => conn.close()
}, [])
const handleSale = async (productId: string, quantity: number) => {
await fetch('/api/inventory/sale', {
method: 'POST',
body: JSON.stringify({ productId, quantity })
})
}
const handleRestock = async (productId: string, quantity: number) => {
await fetch('/api/inventory/restock', {
method: 'POST',
body: JSON.stringify({ productId, quantity })
})
}
return (
<table>
<thead>
<tr>
<th>Product Name</th>
<th>Current Stock</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{products.map(product => (
<tr key={product.id}>
<td>{product.name}</td>
<td>{product.stock}</td>
<td>
<button onClick={() => handleSale(product.id, 1)}>-1</button>
<button onClick={() => handleRestock(product.id, 1)}>+1</button>
</td>
</tr>
))}
</tbody>
</table>
)
}為什麼表格是 Client Component?
WebSocket 連接 - 需要監聽伺服器推送的實時更新
State 管理 - 需要 useState 來更新庫存資料
實時互動 - 用戶點擊銷售/進貨按鈕時,需要立刻反應
好處是什麼?
假設 A 收銀台剛賣出一杯咖啡,庫存從 100 變成 99。Server Component 初始化保證了:
初始資料的準確性(從資料庫直接拿)
多租戶隔離(A 看不到 B 的資料)
業務邏輯安全(自動訂購邏輯在伺服器執行)
Client Component 的 WebSocket 連接保證了:
實時同步(B、C、D 收銀台馬上看到 99)
用戶互動體驗好(不用等待 polling 刷新)
這兩者搭配就是最優的設計。
'use client'
import { useState } from 'react'
export function AddProductForm() {
const [name, setName] = useState('')
const [price, setPrice] = useState(0)
const [stock, setStock] = useState(0)
const [adding, setAdding] = useState(false)
const handleSubmit = async () => {
setAdding(true)
await fetch('/api/inventory/add', {
method: 'POST',
body: JSON.stringify({ name, price, stock })
})
setName('')
setPrice(0)
setStock(0)
setAdding(false)
}
return (
<form onSubmit={e => { e.preventDefault(); handleSubmit() }}>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="Product name"
/>
<input
type="number"
value={price}
onChange={e => setPrice(+e.target.value)}
placeholder="Price"
/>
<input
type="number"
value={stock}
onChange={e => setStock(+e.target.value)}
placeholder="Initial stock"
/>
<button type="submit" disabled={adding}>
{adding ? 'Adding...' : 'Add Product'}
</button>
</form>
)
}新增產品當然是 Client Component,因為需要表單狀態管理和使用者互動。
Server Components 的常見錯誤
第一:props 必須是 serializable。Server Component 傳給 Client Component 的 props 必須能序列化。Date、Map、Set、函數都不行。
export default async function Page() {
const product = await getProduct()
return <ClientComponent product={product} />
}
export default async function Page() {
const product = await getProduct()
return (
<ClientComponent
product={{
...product,
createdAt: product.createdAt.toISOString()
}}
/>
)
}第二:Client Component 不能有 Server Component 子組件
'use client'
import ServerComponent from './ServerComponent'
export default function ClientComponent() {
return <ServerComponent />
}如果 Client Component 需要 Server Component 的資料,得透過 props 從上面傳下來。
第三:執行成本容易被忽視。每個 Server Component 的執行都是成本,如果你有 100 個 Server Components,每次訪問就要執行 100 次。Server Components 省下了 JavaScript 傳輸和 hydration 的開銷,但產生了序列化的問題。
對於簡單的組件,Server Components 省了數百 kb 的 JavaScript,但對於複雜的互動邏輯,還是用得上 Client Components。
和 SSR 的區別
既然 Server Components 也在伺服器執行,為什麼不直接用 SSR?主要的差別是在 hydration,SSR 需要瀏覽器下載 JS 執行 React,然後 hydrate HTML。Server Components 跳過了 hydration,直接用序列化的資料建構 DOM。對 企業內網系統這類 B2B 應用,SSR 和 Server Components 都可以,因為載入速度無關緊要。但對消費者產品,Server Components 會快一點。
下次再來講講 Client Components 和 Hook 的使用,以及怎麼在 Server Components 和 Client Components 之間正確傳遞資料。