之前聊了 Server Components 的優點,也提到了一些 Client Components,究竟有那些東西適合用 Client Component?
策略
架構
成本
適用場景
全 Client
整頁 'use client'
下載 React + 邏輯,200-300KB
CSR 著陸頁、內部系統
局部 Client
Server 初始化 + 小區塊 Client
只傳互動邏輯,20-100KB
電商、POS、內容平台
流式渲染
Server 漸進式推送,Client 並行處理
最優,邊載邊顯示
實時儀表板、複雜列表
Client Component 的正確邊界 不是「這個組件有互動」就用 Client Component。而是「這個邊界之下的部分需要互動」才用 Client Component。
例子 1:電商產品詳情頁 再用電傷產品業做一次舉例。假設頁面有產品資訊、評論、加入購物車、數量選擇、輪播圖片,其中產品資訊和評論不需要互動,只有購物車部分需要。如果把整個頁面都寫成 Client Component,user 剛打開頁面時什麼都看不到。
'use client'
import { useEffect , useState } from 'react'
export default function ProductPage ( { params } ) {
const [ product , setProduct ] = useState ( null )
const [ reviews , setReviews ] = useState ( [ ] )
useEffect ( ( ) => {
Promise .all ( [
fetch ( `/api/products/ ${ params .id } ` ) .then ( r => r .json ( ) ) ,
fetch ( `/api/reviews?productId= ${ params .id } ` ) .then ( r => r .json ( ) )
] ) .then ( ( [ p , r ] ) => {
setProduct ( p )
setReviews ( r )
} )
} , [ params .id ] )
return (
< div >
{ product && < h1 > { product .name } </ h1 > }
{ reviews .map ( review => < div key ={ review .id } > { review .text } </ div > ) }
< AddToCartButton productId ={ params .id } />
</ div >
)
}
這樣寫的問題就是用戶得等待 2-3 秒才看到內容。
現在改用分離方案。頁面本身保持 Server Component,負責初始化資料。然後根據需要,只把有互動的部分改成 Client Component。
首先是頁面本身,Server Component 的做法:
import { db } from '@/lib/database'
import { ProductDetail } from './ProductDetail'
import { ReviewSection } from './ReviewSection'
import { AddToCartButton } from './AddToCartButton'
export default async function ProductPage ( { params } ) {
const product = await db .products .findById ( params .id )
const reviews = await db .reviews .findByProductId ( params .id )
return (
< div >
< ProductDetail product ={ product } />
< ReviewSection reviews ={ reviews } productId ={ params .id } />
< AddToCartButton productId ={ params .id } />
</ div >
)
}
這裡 Server Component 一開始就準備好資料,用戶馬上能看到。
然後產品詳情部分,也是 Server Component,只負責展示:
export function ProductDetail ( { product } ) {
return (
< div >
< h1 > { product .name } </ h1 >
< p > { product .description } </ p >
< p > ${ product .price } </ p >
</ div >
)
}
評論區稍微複雜一點,它有評論列表(靜態,Server Component)和新增評論表單(互動,Client Component):
import { ReviewList } from './ReviewList'
import { AddReviewForm } from './AddReviewForm'
export function ReviewSection ( { reviews , productId } ) {
return (
< div >
< h2 > Reviews</ h2 >
< ReviewList reviews ={ reviews } />
< AddReviewForm productId ={ productId } />
</ div >
)
}
評論列表本身不需要互動,所以可以是 Server Component:
export function ReviewList ( { reviews } ) {
return (
< div >
{ reviews .map ( review => (
< div key ={ review .id } >
< p > { review .text } </ p >
< small > { review .author } </ small >
</ div >
) ) }
</ div >
)
}
只有提交新評論的表單才需要 Client Component,因為它需要 useState 管理輸入內容:
'use client'
import { useState } from 'react'
export function AddReviewForm ( { productId } ) {
const [ text , setText ] = useState ( '' )
const [ submitting , setSubmitting ] = useState ( false )
const handleSubmit = async ( ) => {
setSubmitting ( true )
await fetch ( '/api/reviews' , {
method : 'POST' ,
body : JSON .stringify ( { productId , text } )
} )
setText ( '' )
setSubmitting ( false )
}
return (
< form onSubmit ={ e => { e .preventDefault ( ) ; handleSubmit ( ) } } >
< textarea value ={ text } onChange ={ e => setText ( e .target .value ) } />
< button type ="submit" disabled ={ submitting } >
{ submitting ? 'Submitting...' : 'Submit' }
</ button >
</ form >
)
}
加入購物車的按鈕也是 Client Component,因為需要管理數量:
'use client'
import { useState } from 'react'
export function AddToCartButton ( { productId } ) {
const [ quantity , setQuantity ] = useState ( 1 )
const [ adding , setAdding ] = useState ( false )
const handleAddToCart = async ( ) => {
setAdding ( true )
await fetch ( '/api/cart' , {
method : 'POST' ,
body : JSON .stringify ( { productId , quantity } )
} )
setAdding ( false )
}
return (
< div >
< input
type ="number"
min ="1"
value ={ quantity }
onChange ={ e => setQuantity ( +e .target .value ) }
/>
< button onClick ={ handleAddToCart } disabled ={ adding } >
{ adding ? 'Adding...' : 'Add to Cart' }
</ button >
</ div >
)
}
這樣分離的好處是什麼?用戶點進頁面可以馬上看到產品資訊和評論,只有互動部分(按鈕、表單)是 Client Component。
例子 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</ h1 >
< p > Total Value: ${ totalValue } </ p >
< AddProductForm />
< InventoryTable initialProducts ={ products } />
</ div >
)
}
這裡 Server Component 一次查詢拿出所有產品和統計資訊。
然後是庫存表格,這部分需要實時更新,所以用 Client Component。它接收初始資料,但同時監聽 WebSocket 的即時更新:
'use client'
import { useState , useEffect } from 'react'
export function InventoryTable ( { initialProducts } ) {
const [ products , setProducts ] = useState ( initialProducts )
useEffect ( ( ) => {
const ws = new WebSocket ( 'ws://localhost:8000/inventory' )
ws .onmessage = ( event ) => {
const { type , product } = JSON .parse ( event .data )
if ( type === 'STOCK_UPDATED' ) {
setProducts ( prev =>
prev .map ( p => p .id === product .id ? product : p )
)
}
}
return ( ) => ws .close ( )
} , [ ] )
const handleSale = async ( productId , quantity ) => {
await fetch ( '/api/inventory/sale' , {
method : 'POST' ,
body : JSON .stringify ( { productId , quantity } )
} )
}
return (
< table >
< tbody >
{ products .map ( p => (
< tr key ={ p .id } >
< td > { p .name } </ td >
< td > { p .stock } </ td >
< td >
< button onClick ={ ( ) => handleSale ( p .id , 1 ) } > -1</ button >
</ td >
</ tr >
) ) }
</ tbody >
</ table >
)
}
為什麼這樣設計比較好?一是初始化快,資料庫查詢在伺服器,用戶馬上看到庫存表格。二是實時同步,WebSocket 只在 Client Component,用戶點銷售時馬上看到其他收銀台的更新。三是業務邏輯安全,銷售邏輯在伺服器驗證,用戶無法在客戶端竄改。
常見錯誤:Props 序列化 Server Component 傳給 Client Component 的 props 必須能序列化,也就是轉成 JSON。Date、Function、Map 都不行。
錯誤的例子:
const user = await db .users .findById ( 1 )
return < ClientComponent user ={ user } />
正確的做法是在傳遞前,把 Date 轉成字串:
const user = await db .users .findById ( 1 )
return (
< ClientComponent
user ={ {
id : user .id ,
name : user .name ,
createdAt : user .createdAt .toISOString ( )
} }
/>
)
這樣 props 就能安全序列化了。
Server 和 Client 的資料流向 資料流向是單向的,從 Server 初始化開始,經過 Client 展示,用戶互動,最後透過 API 更新。假設你要做一個設定頁面,用戶可以改主題。Server Component 先初始化當前設定:
import { db } from '@/lib/database'
import { SettingsForm } from './SettingsForm'
export default async function SettingsPage ( ) {
const settings = await db .settings .getCurrent ( )
return < SettingsForm initialSettings ={ settings } />
}
然後 Client Component 展示表單,讓用戶互動。用戶改變選項時,發起 API 請求:
'use client'
import { useState } from 'react'
export function SettingsForm ( { initialSettings } ) {
const [ theme , setTheme ] = useState ( initialSettings .theme )
const [ saving , setSaving ] = useState ( false )
const handleSave = async ( ) => {
setSaving ( true )
await fetch ( '/api/settings' , {
method : 'POST' ,
body : JSON .stringify ( { theme } )
} )
setSaving ( false )
}
return (
< div >
< select value ={ theme } onChange ={ e => setTheme ( e .target .value ) } >
< option value ="light" > Light</ option >
< option value ="dark" > Dark</ option >
</ select >
< button onClick ={ handleSave } disabled ={ saving } >
{ saving ? 'Saving...' : 'Save' }
</ button >
</ div >
)
}
最後是 API Route,負責驗證請求和更新資料庫:
import { db } from '@/lib/database'
export async function POST ( request ) {
const { theme } = await request .json ( )
await db .settings .update ( { theme } )
return Response .json ( { success : true } )
}
這就是完整的循環。初始化、互動、更新、驗證。
React Hook 簡介 Client Component 中會用到 React Hook,詳細的 Hook 用法放在下一篇講,其中基本的 Hook 有三個:
useState 管理組件狀態。當你需要追蹤一個值的變化時,比如按鈕被點擊多少次,用 useState:
useEffect 執行副作用,比如 fetch 或訂閱。當組件初始化或某些值變化時,執行一些邏輯:
useEffect ( ( ) => {
} , [ 依賴 ] )
useCallback 記憶化函數,避免不必要的重新建立。當你要把函數傳給優化過的子組件時用:
const handleClick = useCallback ( ( ) => {
} , [ 依賴 ] )
常見錯誤 1. Client Component 裡嵌套 Server Component
我覺得這個地方容易搞混。有人會想,既然 Client Component 可以包含其他組件,那能不能包含 Server Component?
答案是不能。Client Component 執行在瀏覽器,無法執行 Server Component。Server Component 需要資料庫連接,在瀏覽器跑不了。
'use client'
import ServerComponent from './ServerComponent'
export default function ClientComponent ( ) {
return < ServerComponent />
}
2. 在 Client Component 裡硬編碼 API 邏輯
有人會在 Client Component 裡寫 fetch 邏輯,每次組件重新渲染就發起一個新的 fetch。這樣不只效率差,而且容易產生競態條件。
不好的做法是 Client Component 自己負責資料獲取:
export function ProductCard ( { productId } ) {
const [ product , setProduct ] = useState ( null )
useEffect ( ( ) => {
fetch ( `/api/products/ ${ productId } ` )
.then ( r => r .json ( ) )
.then ( setProduct )
} , [ productId ] )
}
更好的做法是讓 Server Component 初始化,Client Component 只負責展示:
export function ProductCard ( { product } ) {
return < div > { product .name } </ div >
}
我的建議 頁面設計時,問自己這三個問題。
一是哪些部分需要動態資料,那就用 Server Component 初始化。二是哪些部分需要使用者互動,那就用 Client Component。三是邊界在哪裡,儘可能細粒度分割。
我覺得一個好的設計是,80% 的頁面是 Server Component(資料、佈局),20% 是 Client Component(按鈕、表單、modal)。這樣子效能和開發體驗都比較平衡。Server 和 Client 的邊界清楚了,很多複雜性自動消失。你不需要複雜的狀態管理,也不需要擔心 SEO,業務邏輯也更安全。
下次一篇會深入 React Hook,包括 useState 的陷阱、useEffect 的執行順序、怎麼寫自訂 Hook。