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 傳遞結構,確保伺服器端與客戶端元件間的資料序列化正確。
五大核心挑戰
- 水合瀑布流:全量水合導致 JS 執行阻塞主執行緒,TTI 嚴重劣化,低階裝置上互動延遲可達數秒
- Bundle 體積膨脹:伺服器端專用依賴(資料庫驅動、Markdown 解析器)被錯誤打包到客戶端 bundle
- 資料獲取瀑布流:元件巢狀導致資料請求串列化,父元件取得完資料後子元件才能開始請求
- 首屏渲染阻塞:傳統 SSR 必須等所有資料就緒後才能傳送第一個位元組,TTFB 被慢查詢拖垮
- 伺服器端/客戶端元件邊界模糊:開發者難以判斷哪些邏輯應該放在伺服器端,哪些放在客戶端,導致架構混亂
模式一:基礎伺服器端元件與 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約定讓這一切變得簡單直觀。記住核心原則:靜態內容留在伺服器端,互動邏輯送到客戶端,資料在離它最近的地方獲取。
推薦工具
本站提供瀏覽器本地工具,免註冊即可試用 →