Vue3伺服器端元件實戰:從Islands架構到生產級SSR的5種模式

前端工程

SPA的效能困局:為什麼我們需要伺服器端元件

2026年的前端應用越來越複雜,但 SPA 的效能瓶頸卻愈發刺眼。一個典型的 Vue 3 SPA 應用在 Lighthouse 上的表現往往令人沮喪:TTFB 超過 1.5s、FCP 卡在 2s 以上、LCP 動輒 3-4s。根本原因在於——整個應用必須等 JavaScript 下載、解析、執行完畢後,頁面才開始渲染

更致命的是水合(Hydration)成本:一個中等規模的 Vue 應用,其元件樹的水合過程可能消耗 500ms-1s 的 CPU 時間,在低階行動裝置上甚至達到 2-3s。使用者看到頁面已經渲染出來了,但點擊按鈕毫無反應——這就是令人抓狂的「水合間隙」(Hydration Gap)。

伺服器端元件(Server Components)的出現,正是為了從根本上解決這些問題:讓不需要互動的元件永遠不離開伺服器端,只把互動元件送到客戶端

核心概念速查

概念 描述
Server Components 在伺服器端渲染、不傳送 JavaScript 到客戶端的元件,零水合成本
Client Components 包含互動邏輯的元件,需要水合才能回應使用者操作
Islands Architecture 頁面中僅對互動區域進行水合的架構模式,其餘保持靜態 HTML
Hydration 將伺服器端渲染的靜態 HTML 與客戶端 JavaScript 狀態關聯的過程
Streaming SSR 分塊傳輸 HTML,讓瀏覽器逐步渲染頁面而不必等待完整回應
Selective Hydration 僅對使用者正在互動的元件優先水合,而非全量水合

💡 使用 JSON 格式化 工具檢查 Vue 元件的 props 傳遞結構,確保伺服器端與客戶端元件間的資料序列化正確。


五大核心挑戰

  1. 水合瀑布流:全量水合導致 JS 執行阻塞主執行緒,TTI 嚴重劣化,低階裝置上互動延遲可達數秒
  2. Bundle 體積膨脹:伺服器端專用依賴(資料庫驅動、Markdown 解析器)被錯誤打包到客戶端 bundle
  3. 資料獲取瀑布流:元件巢狀導致資料請求串列化,父元件取得完資料後子元件才能開始請求
  4. 首屏渲染阻塞:傳統 SSR 必須等所有資料就緒後才能傳送第一個位元組,TTFB 被慢查詢拖垮
  5. 伺服器端/客戶端元件邊界模糊:開發者難以判斷哪些邏輯應該放在伺服器端,哪些放在客戶端,導致架構混亂

模式一:基礎伺服器端元件與 defineAsyncComponent

最基礎的模式:在伺服器端完成資料獲取,將渲染結果作為靜態 HTML 傳輸,客戶端零水合成本。

伺服器端元件:直接存取資料庫

<!-- components/ArticleList.server.vue -->
<template>
  <section class="max-w-4xl mx-auto px-4">
    <h2 class="text-2xl font-bold text-gray-900 mb-6">最新文章</h2>
    <div class="space-y-4">
      <article
        v-for="article in articles"
        :key="article.id"
        class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
      >
        <h3 class="text-lg font-semibold text-gray-800">{{ article.title }}</h3>
        <p class="text-gray-600 mt-2 line-clamp-2">{{ article.excerpt }}</p>
        <div class="flex items-center gap-2 mt-3 text-sm text-gray-500">
          <span>{{ article.author }}</span>
          <span>·</span>
          <time :datetime="article.publishedAt">{{ formatDate(article.publishedAt) }}</time>
        </div>
      </article>
    </div>
  </section>
</template>

<script lang="ts">
import { db } from '~/server/database'

interface Article {
  id: number
  title: string
  excerpt: string
  author: string
  publishedAt: string
}

export default defineComponent({
  async setup() {
    const articles: Article[] = await db.query(
      'SELECT id, title, excerpt, author, published_at as "publishedAt" FROM articles WHERE status = $1 ORDER BY published_at DESC LIMIT 10',
      ['published']
    )

    const formatDate = (dateStr: string): string => {
      return new Intl.DateTimeFormat('zh-TW', {
        year: 'numeric',
        month: 'long',
        day: 'numeric'
      }).format(new Date(dateStr))
    }

    return { articles, formatDate }
  }
})
</script>

使用 defineAsyncComponent 按需載入

<!-- pages/blog/index.vue -->
<template>
  <div class="min-h-screen bg-gray-50">
    <SiteHeader />
    <ArticleList />
    <SiteFooter />
  </div>
</template>

<script lang="ts">
export default defineComponent({
  components: {
    ArticleList: defineAsyncComponent(() =>
      import('~/components/ArticleList.server.vue')
    )
  }
})
</script>

關鍵要點

  • .server.vue 字尾的元件僅在伺服器端渲染,其 JavaScript 不會出現在客戶端 bundle 中
  • 伺服器端元件可以直接 import 資料庫驅動、檔案系統等 Node.js 模組,無需擔心 bundle 體積
  • defineAsyncComponent 在 SSR 場景下會等待非同步 setup 完成,客戶端則按需載入

模式二:Islands 架構——伺服器端與客戶端元件混合

Islands 架構是 Vue 3 伺服器端元件最核心的實踐模式:頁面主體由伺服器端元件渲染為靜態 HTML,僅在需要互動的「島嶼」區域注入客戶端元件。

Islands 頁面佈局

<!-- pages/index.vue -->
<template>
  <div class="min-h-screen bg-white">
    <SiteHeader />

    <main>
      <HeroSection.server />

      <div class="max-w-7xl mx-auto px-4 py-12">
        <div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
          <div class="lg:col-span-2">
            <ProductGrid.server />
          </div>

          <aside class="space-y-6">
            <ShoppingCart.client />
            <NewsletterSignup.client />
            <RecentPosts.server />
          </aside>
        </div>
      </div>

      <LazyLiveChat.client />
    </main>

    <SiteFooter.server />
  </div>
</template>

客戶端島嶼元件

<!-- components/ShoppingCart.client.vue -->
<template>
  <div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
    <h3 class="font-semibold text-gray-900 mb-3">購物車</h3>

    <div v-if="cartItems.length === 0" class="text-gray-500 text-sm py-4 text-center">
      購物車為空
    </div>

    <div v-else class="space-y-3">
      <div
        v-for="item in cartItems"
        :key="item.id"
        class="flex items-center justify-between text-sm"
      >
        <span class="text-gray-700 truncate">{{ item.name }}</span>
        <div class="flex items-center gap-2">
          <button
            class="w-6 h-6 rounded bg-gray-200 hover:bg-gray-300 flex items-center justify-center"
            @click="decrementItem(item.id)"
          >
            -
          </button>
          <span class="w-6 text-center">{{ item.quantity }}</span>
          <button
            class="w-6 h-6 rounded bg-gray-200 hover:bg-gray-300 flex items-center justify-center"
            @click="incrementItem(item.id)"
          >
            +
          </button>
        </div>
      </div>

      <div class="border-t border-gray-200 pt-3 flex justify-between font-semibold">
        <span>合計</span>
        <span class="text-blue-600">NT${{ totalPrice.toFixed(2) }}</span>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

export default defineComponent({
  setup() {
    const cartItems = ref<CartItem[]>([])

    const totalPrice = computed(() =>
      cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
    )

    const incrementItem = (id: number) => {
      const item = cartItems.value.find(i => i.id === id)
      if (item) item.quantity++
    }

    const decrementItem = (id: number) => {
      const item = cartItems.value.find(i => i.id === id)
      if (item) {
        if (item.quantity > 1) item.quantity--
        else cartItems.value = cartItems.value.filter(i => i.id !== id)
      }
    }

    return { cartItems, totalPrice, incrementItem, decrementItem }
  }
})
</script>

Islands 架構的效能收益

傳統 SSR 頁面水合:
┌──────────────────────────────────────────┐
│  全量水合(500ms - 2s)                    │
│  [Header] [Hero] [Grid] [Cart] [Footer]  │
└──────────────────────────────────────────┘

Islands 架構水合:
┌──────────────────────────────────────────┐
│  靜態 HTML(零 JS)                        │
│  [Header] [Hero] [Grid]     [Footer]     │
│                    ↓                     │
│            [Cart 島嶼水合 50ms]            │
│            [Signup 島嶼水合 30ms]          │
└──────────────────────────────────────────┘
水合時間從 500ms+ 降至 80ms,減少 84%

💡 使用 Base64 編碼 工具編碼伺服器端傳遞給客戶端元件的初始狀態資料,確保傳輸安全。


模式三:Streaming SSR 與 Suspense 邊界

Streaming SSR 讓頁面不再被最慢的元件阻塞——快的資料先傳送,慢的資料後傳送,瀏覽器逐步渲染。

Suspense 邊界劃分策略

<!-- pages/dashboard.vue -->
<template>
  <div class="min-h-screen bg-gray-100">
    <DashboardHeader />

    <main class="max-w-7xl mx-auto px-4 py-6 space-y-6">
      <Suspense>
        <template #default>
          <StatsOverview.server />
        </template>
        <template #fallback>
          <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
            <div v-for="i in 4" :key="i" class="bg-white rounded-lg p-6 animate-pulse">
              <div class="h-4 bg-gray-200 rounded w-1/2 mb-3" />
              <div class="h-8 bg-gray-200 rounded w-3/4" />
            </div>
          </div>
        </template>
      </Suspense>

      <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <Suspense>
          <template #default>
            <RevenueChart.server />
          </template>
          <template #fallback>
            <ChartSkeleton />
          </template>
        </Suspense>

        <Suspense>
          <template #default>
            <UserActivity.server />
          </template>
          <template #fallback>
            <ChartSkeleton />
          </template>
        </Suspense>
      </div>

      <Suspense>
        <template #default>
          <RecentTransactions.server />
        </template>
        <template #fallback>
          <TableSkeleton :rows="5" />
        </template>
      </Suspense>
    </main>
  </div>
</template>

流式資料獲取元件

<!-- components/RevenueChart.server.vue -->
<template>
  <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
    <h3 class="text-lg font-semibold text-gray-900 mb-4">收入趨勢</h3>
    <div class="h-64 flex items-end gap-1">
      <div
        v-for="(item, index) in revenueData"
        :key="index"
        class="flex-1 bg-blue-500 rounded-t transition-all hover:bg-blue-600"
        :style="{ height: `${(item.value / maxValue) * 100}%` }"
        :title="`${item.label}: NT$${item.value.toLocaleString()}`"
      />
    </div>
    <div class="flex justify-between mt-2 text-xs text-gray-500">
      <span>{{ revenueData[0]?.label }}</span>
      <span>{{ revenueData[revenueData.length - 1]?.label }}</span>
    </div>
  </div>
</template>

<script lang="ts">
import { db } from '~/server/database'

interface RevenueItem {
  label: string
  value: number
}

export default defineComponent({
  async setup() {
    const revenueData: RevenueItem[] = await db.query(`
      SELECT
        TO_CHAR(date, 'YYYY-MM') as label,
        SUM(amount) as value
      FROM transactions
      WHERE date >= NOW() - INTERVAL '12 months'
      GROUP BY TO_CHAR(date, 'YYYY-MM')
      ORDER BY label ASC
    `)

    const maxValue = Math.max(...revenueData.map(d => d.value))

    return { revenueData, maxValue }
  }
})
</script>

Nuxt 4 流式 SSR 設定

export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4
  },

  ssr: true,

  experimental: {
    componentIslands: true,
    asyncContext: true,
    viewTransition: true
  },

  nitro: {
    preset: 'node-cluster',
    compressPublicAssets: { brotli: true },
    experimental: {
      wasm: true,
      database: true
    }
  },

  routeRules: {
    '/dashboard': { ssr: true },
    '/dashboard/analytics': { swr: 300 },
    '/dashboard/realtime': { ssr: true }
  }
})

模式四:資料獲取策略——伺服器端 vs 客戶端

理解何時在伺服器端獲取資料、何時在客戶端獲取資料,是建構高效能混合應用的關鍵。

四種資料獲取策略對比

策略 適用場景 TTFB 影響 客戶端 JS 互動性
伺服器端直接查詢 靜態內容、SEO 關鍵頁面 高(等待查詢)
useFetch SSR 需要客戶端互動的資料 中(雙重獲取)
客戶端 useFetch 使用者操作觸發的資料 無影響
混合策略 複雜頁面,部分靜態部分動態 混合

伺服器端直接查詢(零客戶端 JS)

<!-- components/StaticContent.server.vue -->
<template>
  <div class="prose max-w-none" v-html="renderedContent" />
</template>

<script lang="ts">
import { readFile } from 'node:fs/promises'
import { renderMarkdown } from '~/server/utils/markdown'

export default defineComponent({
  async setup() {
    const rawContent = await readFile('./content/about.md', 'utf-8')
    const renderedContent = renderMarkdown(rawContent)

    return { renderedContent }
  }
})
</script>

useFetch SSR 模式(雙端共享)

<!-- pages/products/[id].vue -->
<template>
  <div class="max-w-7xl mx-auto px-4 py-8">
    <div v-if="pending" class="animate-pulse space-y-4">
      <div class="h-8 bg-gray-200 rounded w-1/3" />
      <div class="h-64 bg-gray-200 rounded" />
    </div>

    <div v-else-if="error" class="text-center py-12">
      <p class="text-red-600">載入失敗:{{ error.message }}</p>
      <button
        class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
        @click="refresh()"
      >
        重試
      </button>
    </div>

    <template v-else-if="data">
      <ProductDetail :product="data" />
      <RelatedProducts :category-id="data.categoryId" />
    </template>
  </div>
</template>

<script lang="ts">
interface Product {
  id: number
  name: string
  price: number
  description: string
  categoryId: number
  images: string[]
}

export default defineComponent({
  setup() {
    const route = useRoute()

    const { data, pending, error, refresh } = useFetch<Product>(
      `/api/products/${route.params.id}`,
      {
        key: `product-${route.params.id}`,
        default: () => null,
        watch: [() => route.params.id]
      }
    )

    return { data, pending, error, refresh }
  }
})
</script>

客戶端按需獲取(互動驅動)

<!-- components/ProductReviews.client.vue -->
<template>
  <div class="mt-8">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-semibold">使用者評價</h3>
      <button
        class="text-sm text-blue-600 hover:text-blue-800"
        @click="loadMore"
      >
        {{ hasMore ? '載入更多' : '已全部載入' }}
      </button>
    </div>

    <div class="space-y-4">
      <ReviewCard
        v-for="review in reviews"
        :key="review.id"
        :review="review"
      />
    </div>

    <div v-if="isLoadingMore" class="text-center py-4">
      <span class="text-gray-500">載入中...</span>
    </div>
  </div>
</template>

<script lang="ts">
interface Review {
  id: number
  userName: string
  rating: number
  content: string
  createdAt: string
}

export default defineComponent({
  props: {
    productId: { type: Number, required: true }
  },
  setup(props) {
    const reviews = ref<Review[]>([])
    const page = ref(1)
    const hasMore = ref(true)
    const isLoadingMore = ref(false)

    const loadMore = async () => {
      if (isLoadingMore.value || !hasMore.value) return

      isLoadingMore.value = true
      try {
        const { data } = await useFetch<Review[]>(
          `/api/products/${props.productId}/reviews`,
          { query: { page: page.value, pageSize: 10 } }
        )
        if (data.value && data.value.length > 0) {
          reviews.value.push(...data.value)
          page.value++
        } else {
          hasMore.value = false
        }
      } finally {
        isLoadingMore.value = false
      }
    }

    onMounted(() => loadMore())

    return { reviews, hasMore, isLoadingMore, loadMore }
  }
})
</script>

混合策略:伺服器端預取 + 客戶端增量

<!-- pages/search.vue -->
<template>
  <div class="max-w-7xl mx-auto px-4 py-8">
    <SearchBar.client
      :initial-query="route.query.q as string"
      @search="handleSearch"
    />

    <div class="mt-6">
      <Suspense>
        <template #default>
          <SearchResults.server :query="currentQuery" />
        </template>
        <template #fallback>
          <SearchResultsSkeleton />
        </template>
      </Suspense>
    </div>
  </div>
</template>

<script lang="ts">
export default defineComponent({
  setup() {
    const route = useRoute()
    const router = useRouter()
    const currentQuery = ref(route.query.q as string || '')

    const handleSearch = (query: string) => {
      currentQuery.value = query
      router.push({ query: { q: query } })
    }

    return { route, currentQuery, handleSearch }
  }
})
</script>

模式五:生產級部署——Nuxt 4 伺服器端元件

將前四種模式整合到生產級 Nuxt 4 專案中,涵蓋設定、部署和監控。

完整專案結構

vue3-sc-app/
├── app/
│   ├── pages/
│   │   ├── index.vue
│   │   ├── products/
│   │   │   ├── index.vue
│   │   │   └── [id].vue
│   │   ├── blog/
│   │   │   ├── index.vue
│   │   │   └── [slug].vue
│   │   └── dashboard.vue
│   ├── components/
│   │   ├── ProductGrid.server.vue
│   │   ├── ProductCard.vue
│   │   ├── ShoppingCart.client.vue
│   │   ├── SearchBar.client.vue
│   │   ├── NewsletterSignup.client.vue
│   │   ├── RevenueChart.server.vue
│   │   └── SiteHeader.server.vue
│   ├── composables/
│   │   ├── useCart.ts
│   │   ├── useSearch.ts
│   │   └── useAnalytics.ts
│   ├── layouts/
│   │   └── default.vue
│   └── app.vue
├── server/
│   ├── api/
│   │   ├── products/
│   │   │   ├── index.ts
│   │   │   └── [id].ts
│   │   ├── search.ts
│   │   └── cart.ts
│   ├── middleware/
│   │   ├── auth.ts
│   │   └── rateLimit.ts
│   ├── database.ts
│   └── utils/
│       ├── markdown.ts
│       └── cache.ts
├── public/
├── nuxt.config.ts
├── package.json
└── tsconfig.json

生產級 Nuxt 設定

import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4
  },

  ssr: true,

  routeRules: {
    '/': { prerender: true },
    '/products/**': { swr: 3600 },
    '/blog/**': { isr: 600 },
    '/dashboard': { ssr: true },
    '/admin/**': { ssr: false },
    '/api/**': { cors: true }
  },

  nitro: {
    preset: 'node-cluster',
    compressPublicAssets: { brotli: true },
    cacheDir: '.nitro-cache',
    experimental: {
      wasm: true,
      database: true
    },
    rollupConfig: {
      external: ['pg', 'sharp', 'better-sqlite3']
    }
  },

  experimental: {
    componentIslands: true,
    asyncContext: true,
    viewTransition: true,
    typedPages: true
  },

  modules: [
    '@nuxtjs/tailwindcss',
    '@nuxt/image',
    '@nuxt/fonts'
  ],

  image: {
    quality: 80,
    formats: ['avif', 'webp']
  },

  app: {
    head: {
      charset: 'utf-8',
      viewport: 'width=device-width, initial-scale=1',
      titleTemplate: '%s | Vue3 SC App'
    }
  }
})

伺服器端 API 路由

import { defineEventHandler, getQuery, createError } from 'h3'
import { db } from '~/server/database'

export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const page = Number(query.page) || 1
  const pageSize = Number(query.pageSize) || 24
  const category = query.category as string | undefined

  const offset = (page - 1) * pageSize

  const whereClause = category
    ? 'WHERE p.status = $1 AND p.category = $2'
    : 'WHERE p.status = $1'
  const params = category
    ? ['published', category]
    : ['published']

  const [products, countResult] = await Promise.all([
    db.query(
      `SELECT id, name, price, image_url, category
       FROM products p
       ${whereClause}
       ORDER BY created_at DESC
       LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
      [...params, pageSize, offset]
    ),
    db.query(
      `SELECT COUNT(*) as total FROM products p ${whereClause}`,
      params
    )
  ])

  return {
    items: products,
    total: Number(countResult[0].total),
    page,
    pageSize,
    totalPages: Math.ceil(Number(countResult[0].total) / pageSize)
  }
})

Docker 生產部署

FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner

WORKDIR /app
COPY --from=builder /app/.output ./.output

ENV HOST=0.0.0.0
ENV PORT=3000
ENV NODE_ENV=production

EXPOSE 3000

CMD ["node", ".output/server/index.mjs"]

健康檢查與監控

import { defineEventHandler } from 'h3'

export default defineEventHandler(async () => {
  const startTime = Date.now()

  try {
    await db.query('SELECT 1')
    return {
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime: process.uptime(),
      database: 'connected',
      responseTime: `${Date.now() - startTime}ms`
    }
  } catch (error) {
    throw createError({
      statusCode: 503,
      statusMessage: 'Service Unavailable',
      data: {
        status: 'unhealthy',
        database: 'disconnected',
        responseTime: `${Date.now() - startTime}ms`
      }
    })
  }
})

五大常見陷阱

陷阱一:在伺服器端元件中使用客戶端 API

錯誤寫法

<!-- components/BrokenCounter.server.vue -->
<template>
  <div>
    <p>計數:{{ count }}</p>
    <button @click="count++">增加</button>
  </div>
</template>

<script lang="ts">
export default defineComponent({
  setup() {
    const count = ref(0)
    onMounted(() => {
      console.log('mounted') // 永遠不會執行
    })
    return { count }
  }
})
</script>

正確寫法

<!-- components/Counter.client.vue -->
<template>
  <div>
    <p>計數:{{ count }}</p>
    <button @click="count++">增加</button>
  </div>
</template>

<script lang="ts">
export default defineComponent({
  setup() {
    const count = ref(0)
    return { count }
  }
})
</script>

陷阱二:伺服器端元件向客戶端元件傳遞不可序列化資料

錯誤寫法

<!-- components/BrokenPass.server.vue -->
<template>
  <InteractiveChart :renderer="chartRenderer" :data="rawBuffer" />
</template>

<script lang="ts">
import { createChartRenderer } from 'heavy-chart-lib'
import { readFileSync } from 'node:fs'

export default defineComponent({
  async setup() {
    const chartRenderer = createChartRenderer() // 函式,不可序列化
    const rawBuffer = readFileSync('./data.bin') // Buffer,不可序列化

    return { chartRenderer, rawBuffer }
  }
})
</script>

正確寫法

<!-- components/FixedPass.server.vue -->
<template>
  <InteractiveChart :chart-config="chartConfig" :data-json="dataJson" />
</template>

<script lang="ts">
import { readFileSync } from 'node:fs'

export default defineComponent({
  async setup() {
    const rawData = JSON.parse(readFileSync('./data.json', 'utf-8'))
    const chartConfig = { type: 'bar', animated: true }

    const dataJson = JSON.stringify(rawData)

    return { chartConfig, dataJson }
  }
})
</script>

陷阱三:全量水合而非 Islands 模式

錯誤寫法

<!-- pages/index.vue - 全量水合 -->
<template>
  <div>
    <Header />
    <HeroSection />
    <ProductGrid />
    <Footer />
  </div>
</template>

正確寫法

<!-- pages/index.vue - Islands 模式 -->
<template>
  <div>
    <Header.server />
    <HeroSection.server />
    <ProductGrid.server />
    <ShoppingCart.client />
    <Footer.server />
  </div>
</template>

陷阱四:在 Suspense 中缺少 Fallback

錯誤寫法

<template>
  <Suspense>
    <SlowDataComponent.server />
  </Suspense>
</template>

正確寫法

<template>
  <Suspense>
    <template #default>
      <SlowDataComponent.server />
    </template>
    <template #fallback>
      <LoadingSkeleton />
    </template>
  </Suspense>
</template>

陷阱五:伺服器端元件中洩露環境變數

錯誤寫法

<!-- components/BrokenEnv.server.vue -->
<template>
  <div>API Key: {{ apiKey }}</div>
</template>

<script lang="ts">
export default defineComponent({
  setup() {
    const apiKey = process.env.SECRET_API_KEY
    return { apiKey } // 會被序列化到 HTML 中!
  }
})
</script>

正確寫法

<!-- components/SecureEnv.server.vue -->
<template>
  <div>服務狀態:{{ status }}</div>
</template>

<script lang="ts">
export default defineComponent({
  async setup() {
    const apiKey = process.env.SECRET_API_KEY
    const response = await fetch(`https://api.example.com/health`, {
      headers: { Authorization: `Bearer ${apiKey}` }
    })
    const status = response.ok ? '正常' : '異常'

    return { status } // 只回傳處理後的結果
  }
})
</script>

錯誤排查速查表

錯誤訊息 原因 解決方案
Hydration mismatch 伺服器端渲染 HTML 與客戶端水合結果不一致 檢查伺服器端/客戶端資料一致性,使用 ClientOnly 包裹
Cannot read property of null 伺服器端元件非同步資料未就緒 新增 Suspense fallback 或 v-if 守衛
Module not found: 'node:fs' 客戶端元件引用了 Node.js 模組 將該元件改為 .server.vue 或將邏輯移至 API 路由
props serialization failed 傳遞了不可序列化的 props(函式、Buffer等) 僅傳遞 JSON 可序列化資料,函式移至客戶端元件
Maximum call stack exceeded 伺服器端/客戶端元件循環引用 檢查元件巢狀關係,避免 .server.vue 引用 .client.vue 再引用 .server.vue
useFetch returned null SSR 期間 API 請求失敗 檢查 API 路由、新增 default 值、確認伺服器端網路可達
Text content did not match 伺服器端渲染日期/隨機數與客戶端不同 使用 ClientOnly 包裹動態內容,或固定隨機種子
500 Internal Server Error 伺服器端元件 setup 拋出例外 新增 try-catch,檢查資料庫連線和檔案路徑
Component is not rendered .client.vue 在 SSR 時輸出空節點 這是正常行為,確認客戶端水合後正常顯示
Performance warning: large props 伺服器端元件傳遞大量資料給客戶端元件 精簡 props,僅傳遞必要欄位,大資料使用 API 按需獲取

💡 使用 Hash 計算工具 為伺服器端元件的渲染結果產生校驗值,快速定位水合不匹配問題。


進階最佳化技巧

1. 元件級快取策略

對計算密集但資料更新頻率低的伺服器端元件實施快取,避免每次請求都重新渲染。

import { defineEventHandler, getHeader, setHeader } from 'h3'
import { renderToString } from 'vue/server-renderer'
import { createSSRApp } from 'vue'
import { LRUCache } from 'lru-cache'

const componentCache = new LRUCache<string, string>({
  max: 500,
  ttl: 1000 * 60 * 5
})

export async function renderCachedComponent(
  component: any,
  props: Record<string, any>,
  cacheKey: string
): Promise<string> {
  const cached = componentCache.get(cacheKey)
  if (cached) return cached

  const app = createSSRApp(component, props)
  const html = await renderToString(app)

  componentCache.set(cacheKey, html)
  return html
}

2. 選擇性水合優先順序控制

根據使用者互動意圖動態調整水合順序,優先水合使用者即將互動的元件。

<!-- composables/useSelectiveHydration.ts -->
<script lang="ts">
interface HydrationPriority {
  componentId: string
  priority: number
  element: HTMLElement | null
}

export function useSelectiveHydration() {
  const hydrationQueue = ref<HydrationPriority[]>([])
  const hydratedSet = ref<Set<string>>(new Set())

  const scheduleHydration = (
    componentId: string,
    priority: number,
    element: HTMLElement | null
  ) => {
    if (hydratedSet.value.has(componentId)) return

    hydrationQueue.value.push({ componentId, priority, element })
    hydrationQueue.value.sort((a, b) => b.priority - a.priority)

    requestIdleCallback(() => processQueue())
  }

  const processQueue = () => {
    while (hydrationQueue.value.length > 0) {
      const item = hydrationQueue.value.shift()!
      if (item.element && !hydratedSet.value.has(item.componentId)) {
        hydratedSet.value.add(item.componentId)
        item.element.dataset.hydrated = 'true'
      }
    }
  }

  const onInteraction = (componentId: string) => {
    const item = hydrationQueue.value.find(i => i.componentId === componentId)
    if (item) item.priority = 100
    processQueue()
  }

  return { scheduleHydration, onInteraction, hydratedSet }
}
</script>

3. 流式傳輸中的漸進式增強

在 Streaming SSR 過程中,為尚未水合的元件提供基礎 HTML 互動能力,確保使用者在水合完成前也能進行基本操作。

<!-- components/ProgressiveButton.client.vue -->
<template>
  <button
    class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
    :class="{ 'animate-pulse': isHydrating }"
    :disabled="isHydrating"
    @click="handleClick"
  >
    <slot>點擊</slot>
  </button>
</template>

<script lang="ts">
export default defineComponent({
  emits: ['click'],
  setup(_, { emit }) {
    const isHydrating = ref(true)

    onMounted(() => {
      nextTick(() => {
        isHydrating.value = false
      })
    })

    const handleClick = () => {
      if (!isHydrating.value) {
        emit('click')
      }
    }

    return { isHydrating, handleClick }
  }
})
</script>

框架橫向對比

特性 Next.js RSC Nuxt Server Components Astro Islands Qwik Resumability
核心理念 React Server Components Vue Server Components 零 JS 預設 可恢復性
伺服器端元件 "use server" 指令 .server.vue 檔案約定 預設行為 全部伺服器端序列化
客戶端元件 "use client" 指令 .client.vue 檔案約定 client:load 指令 按需恢復
水合策略 全量水合(客戶端元件) Islands 選擇性水合 Islands 按需水合 零水合(Resumability)
流式 SSR ✅ 內建 ✅ Suspense + Streaming ✅ 內建 ✅ 內建
資料獲取 Server Actions / fetch 直接 DB / useFetch Astro.glob / fetch server$ 函式
Bundle 最佳化 伺服器端依賴不打包 伺服器端依賴不打包 預設零 JS 極致按需載入
Vue 生態 ❌ React only ✅ 原生 ✅ 支援 Vue ❌ Qwik only
React 生態 ✅ 原生 ❌ Vue only ✅ 支援 React ❌ Qwik only
學習曲線 中等 低(Vue 開發者)
生產就緒 ✅ 2023+ ✅ 2025+ ✅ 2023+ ⚠️ 生態建設中
適用場景 複雜 React 應用 Vue 全端應用 內容站 / 部落格 極致效能需求

總結

Vue 3 伺服器端元件不是銀彈,但它為 Vue 生態提供了一種精確控制水合邊界的能力。Islands 架構讓我們只水合需要互動的部分,Streaming SSR 讓頁面不再被慢查詢阻塞,而 Nuxt 4 的 .server.vue / .client.vue 約定讓這一切變得簡單直觀。記住核心原則:靜態內容留在伺服器端,互動邏輯送到客戶端,資料在離它最近的地方獲取


推薦工具

  • JSON 格式化 — 檢查 Nuxt 設定和 API 回應結構
  • Base64 編碼 — 編碼伺服器端傳遞給客戶端的初始狀態資料
  • Hash 計算 — 為伺服器端渲染結果產生校驗值,排查水合不匹配

本站提供瀏覽器本地工具,免註冊即可試用 →

#Vue3#Server Components#SSR#Nuxt#前端性能#2026#Islands Architecture