Blog

/

Frontend

Next.js - Server Components

Server Components 使 Client Components 使

Posted at

2024年10月22日

Posted on

Frontend

Server Components 和 Client Components

Server Components:組件代碼在伺服器執行,永遠不會被傳到瀏覽器。你可以直接查資料庫、訪問環境變數、執行任何 Node.js 操作。最後伺服器把組件生成的資料發送給客戶端。

// app/products/page.tsx (這是 Server Component,預設)
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。

// app/components/Counter.tsx (這是 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:頁面本身

// app/products/[id]/page.tsx (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} /> {/* Client Component */}
      <ReviewSection reviews={reviews} productId={product.id} /> {/* 混合 */}
      <RelatedProducts products={relatedProducts} />
    </div>
  )
}

為什麼頁面本身是 Server Component?因為它需要做幾件事:

  1. 查資料庫拿產品資訊 - 只有伺服器端可以安全地做到,credentials 不會洩露

  2. 複雜的資料庫查詢 - findRelated 可能涉及複雜的 SQL join,直接在伺服器執行更簡單

  3. SEO - 搜索引擎爬蟲看到完整的 HTML,包括產品資訊和評論

這樣做有甚麼好處?如果整個頁面都是 Client Component,你需要傳上百 KB 的 JavaScript 到瀏覽器(React、狀態管理、API 客戶端等)。用戶要等 2-3 秒才能看到產品資訊。

用 Server Component,產品資訊已經在 HTML 裡了,只要用戶打開頁面 0.5 秒就能看到,然後 JavaScript 還也許只有 30KB(只是加入購物車和新增評論的邏輯)。

Client Component:加入購物車按鈕
// app/products/[id]/AddToCartButton.tsx (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?因為它需要:

  1. State 管理 - 數量的增減需要 useState

  2. 事件處理 - 點擊按鈕的反應

  3. Loading 狀態 - 用戶提交後要顯示「Adding...」

Server Component 沒有這些能力,所以必須用 Client Component。

評論區,混用是個不錯的選項
// app/products/[id]/ReviewSection.tsx (Server 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} /> {/* Server Component */}
      <AddReviewForm productId={productId} /> {/* Client Component */}
    </div>
  )
}

評論列表由 Server Component 展示(因為資料已經準備好了),新增評論由 Client Component 處理(因為需要表單互動)。

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

這個設計解決了什麼問題?

  1. 載入速度快 - 產品資訊馬上展示,不用等 JavaScript

  2. 資料庫安全 - credentials 永遠在伺服器,不會洩露給瀏覽器

  3. 開發簡單 - 不需要複雜的狀態管理來同步伺服器資料

  4. SEO 友善 - 爬蟲看到完整的 HTML

實例 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() {
  // 初始化時從資料庫拿所有產品
  const products = await db.inventory.getAllProducts()
  const totalValue = await db.inventory.calculateTotalValue()

  return (
    <div>
      <h1>Inventory Management</h1>
      <p>Total Value: ${totalValue}</p>
      
      <AddProductForm /> {/* Client Component - 新增產品 */}
      <InventoryTable initialProducts={products} /> {/* Client Component - 實時更新 */}
    </div>
  )
}

為什麼要用 Server Component 初始化?

  1. 多租戶隔離 - Server Component 可以在伺服器端驗證租戶身份,確保店家 A 看不到店家 B 的庫存

  2. 複雜業務邏輯 - 檢查「庫存低於 10 時自動訂購」這種規則,應該在伺服器端執行,而不是客戶端(否則聰明的用戶可能改 JavaScript 破壞規則)

  3. 初始化速度 - 用戶早上 7 點打開系統時,庫存資料已經準備好了

Client Component:實時監聽更新
// app/inventory/InventoryTable.tsx (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(() => {
    // 連接 WebSocket 接收實時更新
    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) => {
    // 發送銷售事件到後端
    // 後端會更新資料庫,然後透過 WebSocket 推送給所有連接
    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?

  1. WebSocket 連接 - 需要監聽伺服器推送的實時更新

  2. State 管理 - 需要 useState 來更新庫存資料

  3. 實時互動 - 用戶點擊銷售/進貨按鈕時,需要立刻反應

好處是什麼?

假設 A 收銀台剛賣出一杯咖啡,庫存從 100 變成 99。Server Component 初始化保證了:

  1. 初始資料的準確性(從資料庫直接拿)

  2. 多租戶隔離(A 看不到 B 的資料)

  3. 業務邏輯安全(自動訂購邏輯在伺服器執行)

Client Component 的 WebSocket 連接保證了:

  1. 實時同步(B、C、D 收銀台馬上看到 99)

  2. 用戶互動體驗好(不用等待 polling 刷新)

這兩者搭配就是最優的設計。

// app/inventory/AddProductForm.tsx (Client Component)
'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()
  // product.createdAt 是 Date 物件,無法序列化
  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 之間正確傳遞資料。