Blog

/

Frontend

Next.js - Client Components

Client ComponentServer Component Client Component80/20 SEO

Posted at

2024年10月23日

Posted on

Frontend

之前聊了 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 剛打開頁面時什麼都看不到。

// app/products/[id]/page.tsx
'use client' // 整個頁面都變成 Client Component

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 的做法:

// app/products/[id]/page.tsx (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 }) {
  // Server Component 初始化
  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,只負責展示:

// app/products/[id]/ProductDetail.tsx (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):

// app/products/[id]/ReviewSection.tsx (Server 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:

// app/products/[id]/ReviewList.tsx (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 管理輸入內容:

// app/products/[id]/AddReviewForm.tsx (Client Component)
'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,因為需要管理數量:

// app/products/[id]/AddToCartButton.tsx (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,負責初始化:








// app/inventory/page.tsx (Server Component)
import { db } from '@/lib/database'
import { InventoryTable } from './InventoryTable'
import { AddProductForm } from './AddProductForm'

export default async function InventoryPage() {
  // Server Component 初始化
  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 的即時更新:

// app/inventory/InventoryTable.tsx (Client Component)
'use client'

import { useState, useEffect } from 'react'

export function InventoryTable({ initialProducts }) {
  const [products, setProducts] = useState(initialProducts)

  useEffect(() => {
    // 建立 WebSocket 監聽實時更新
    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 都不行。

錯誤的例子:

// app/page.tsx (Server Component)
const user = await db.users.findById(1)
// user.createdAt 是 Date 物件

return <ClientComponent user={user} /> // 序列化失敗

正確的做法是在傳遞前,把 Date 轉成字串:

// app/page.tsx (Server Component)
const user = await db.users.findById(1)

return (
  <ClientComponent 
    user={{
      id: user.id,
      name: user.name,
      createdAt: user.createdAt.toISOString() // Date 轉 ISO string
    }}
  />
)

這樣 props 就能安全序列化了。

Server 和 Client 的資料流向

資料流向是單向的,從 Server 初始化開始,經過 Client 展示,用戶互動,最後透過 API 更新。假設你要做一個設定頁面,用戶可以改主題。Server Component 先初始化當前設定:

// app/settings/page.tsx (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 請求:

// app/settings/SettingsForm.tsx (Client Component)
'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)
    
    // 發 API 更新
    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,負責驗證請求和更新資料庫:

// app/api/settings/route.ts (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:

const [count, setCount] = useState(0)

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 只負責展示:

// 更好:讓 Server 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。

More Blog