Vue3 – 效能與最佳化:SSR Hydration 效能調整
簡介
在現代 Web 開發中,Server‑Side Rendering (SSR) 已成為提升首屏渲染速度與 SEO 表現的關鍵技術。Vue 3 提供了完整的 SSR 解決方案,讓開發者可以在伺服器端預先產生 HTML,然後在瀏覽器端「hydrate」成可互動的 Vue 應用。
然而,hydration 本身也會帶來額外的 JavaScript 執行成本,特別是大型單頁應用 (SPA) 時,若不加以優化,使用者仍會感受到卡頓或長時間的阻塞。本文將從概念、實作到常見陷阱,完整說明如何在 Vue 3 中調整 SSR hydration 的效能,讓你的網站既快又穩。
核心概念
1. Hydration 是什麼?
Hydration 指的是瀏覽器收到伺服器傳回的靜態 HTML 後,把這些已渲染的節點掛載 (mount) 到 Vue 實例上,使之具備雙向資料綁定、事件監聽等互動功能。簡單來說,SSR 產生的是「可見的靜態頁面」,而 hydration 則是把「**靜態」」變成「**動態」」的過程。
重點:如果 hydration 的成本過高,使用者仍會在「看見內容」與「可以操作」之間等待較長時間,失去 SSR 的優勢。
2. 為什麼 Hydration 會消耗資源?
| 項目 | 可能的效能瓶頸 |
|---|---|
| 虛擬 DOM 比對 | 伺服器端渲染的 HTML 必須與客戶端重新產生的 VNode 結構逐一比對。 |
| 事件綁定 | 需要在每個節點上掛載監聽器,對大量互動元素尤為費時。 |
| 大型組件樹 | 整棵組件樹一次性 hydrate,會導致大量同步 JavaScript 執行。 |
| 同步資料請求 | 若在 setup() 中直接發起 API 請求,會阻塞 hydration 完成。 |
3. 基本的 Hydration 流程
flowchart TD
A[伺服器端渲染 (SSR)] --> B[回傳 HTML + 初始 state]
B --> C[瀏覽器載入 HTML]
C --> D[Vue createSSRApp() 產生客戶端應用]
D --> E[hydrate() 比對 & 事件綁定]
E --> F[應用可互動]
4. 核心優化策略
分段 Hydration(Partial / Lazy Hydration)
只在需要時才 hydrate 某些區塊,降低首次渲染的 JavaScript 量。組件層級的懶載入(Async Component)
把非首屏組件以defineAsyncComponent包裝,延遲載入與 hydrate。使用
v-memo防止不必要的比對
讓 Vue 在相同輸入下跳過 VNode 重新渲染。Streaming + Suspense
先送出可立即 hydrate 的內容,後續再流式傳送其餘區塊。Cache & 預取 (Prefetch) 靜態資源
透過 HTTP cache、Service Worker 或<link rel="preload">減少網路延遲。
以下將以實務範例說明每項技巧的具體寫法與效能影響。
程式碼範例
範例 1️⃣:基本的 SSR 設定與 Hydration
// server.js (Node)
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from './src/App.vue'
export async function renderPage(url) {
const app = createSSRApp(App)
// 可在此加入 router、store 等
const html = await renderToString(app)
const state = {} // 初始 state,若有 Vuex / Pinia 需序列化
return { html, state }
}
// client-entry.js (瀏覽器入口)
import { createSSRApp, hydrate } from 'vue'
import App from './src/App.vue'
const app = createSSRApp(App)
// 假設伺服器已把 initial state 放在 window.__INITIAL_STATE__
if (window.__INITIAL_STATE__) {
// 把 state 注入到 store (Pinia / Vuex)
}
// **hydrate** 會自動在 #app 容器上執行比對與掛載
hydrate(app, document.getElementById('app'))
說明:此為最簡單的 SSR + Hydration 流程,適合作為後續優化的基礎。
範例 2️⃣:分段 Hydration – 只 hydrate 首屏區塊
<!-- index.html -->
<body>
<div id="app"><!-- SSR 產生的完整 HTML --></div>
<!-- 只 hydrate 首屏的 hero 區塊 -->
<script type="module">
import { createSSRApp, hydrate } from 'vue'
import Hero from './src/components/Hero.vue'
const app = createSSRApp({
render: () => h('div', { id: 'hero' }, [h(Hero)])
})
// 只在 #hero 節點上進行 hydrate
hydrate(app, document.getElementById('hero'))
</script>
<!-- 其餘非首屏區塊採用 lazy hydration -->
<script src="/lazy-hydration.js" defer></script>
</body>
// lazy-hydration.js
import { createSSRApp, hydrate } from 'vue'
import Content from './src/components/Content.vue'
document.addEventListener('DOMContentLoaded', () => {
const app = createSSRApp({
render: () => h('div', { id: 'content' }, [h(Content)])
})
hydrate(app, document.getElementById('content'))
})
效能說明:首屏只 hydrate
#hero,其餘內容在DOMContentLoaded後才開始,減少首次 JS 執行量,提升 First Input Delay (FID)。
範例 3️⃣:Async Component + Suspense
// src/components/AsyncChart.vue
import { defineAsyncComponent } from 'vue'
export default defineAsyncComponent({
// 只在客戶端載入圖表套件 (如 Chart.js)
loader: () => import('./Chart.vue'),
// 加載期間顯示 fallback
loadingComponent: {
template: '<div class="loading">Loading chart...</div>'
},
// 超時或錯誤時顯示的組件
errorComponent: {
template: '<div class="error">Failed to load chart.</div>'
},
// 延遲 200ms 再開始載入,避免短暫顯示 loading
delay: 200,
// 最長等待時間,超過則觸發 errorComponent
timeout: 3000
})
<!-- 使用 Suspense 包住 AsyncChart -->
<template>
<Suspense>
<template #default>
<AsyncChart />
</template>
<template #fallback>
<div class="placeholder">圖表載入中…</div>
</template>
</Suspense>
</template>
<script setup>
import AsyncChart from '@/components/AsyncChart.vue'
</script>
效能說明:
AsyncChart只在需要展示圖表時才載入,SSR 階段會輸出fallback的占位 HTML,減少首屏 JS 大小,同時保持 SEO。
範例 4️⃣:v-memo 防止重複比對
<!-- 假設有大量列表需要渲染 -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<!-- 若 item 本身不會變,使用 v-memo 跳過比對 -->
<div v-memo="[item.id]">
{{ item.name }}
</div>
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
// ...上千筆資料
])
</script>
說明:
v-memo會在相同依賴 (item.id) 時直接重用先前的 VNode,減少 SSR Hydration 時的虛擬 DOM 比對成本,對大型列表尤為有效。
範例 5️⃣:Streaming + Suspense 結合 Node.js
// server-stream.js
import { createSSRApp, renderToPipeableStream } from '@vue/server-renderer'
import App from './src/App.vue'
export function handleRequest(req, res) {
const app = createSSRApp(App)
const { pipe } = renderToPipeableStream(app, {
// 當首屏內容可供渲染時立即 flush
onShellReady() {
res.setHeader('Content-Type', 'text/html')
pipe(res)
},
// 若渲染失敗則回傳 fallback
onError(err) {
console.error(err)
}
})
}
<!-- App.vue 中使用 Suspense -->
<template>
<Header />
<Suspense>
<template #default>
<HeavyComponent />
</template>
<template #fallback>
<div class="loading">Loading…</div>
</template>
</Suspense>
<Footer />
</template>
效能說明:
renderToPipeableStream會在onShellReady時立即把已完成的 HTML 片段送給瀏覽器,先呈現可立即互動的部份,而HeavyComponent透過Suspense延遲載入,避免阻塞首屏渲染。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 解決方式 |
|---|---|---|
在 setup() 中直接呼叫同步 API |
Hydration 會被阻塞,導致白屏或長時間卡頓 | 使用 onServerPrefetch 或 預先在伺服器端取得資料,在客戶端改用 onMounted |
| 過度使用全局狀態 (Pinia / Vuex) 並一次性注入 | 初始 state 變大,傳輸與解析成本提升 | 分模組、只注入首屏需要的 state,其餘透過 lazy fetch |
將大量 CSS 直接寫在 <style> 中 |
伺服器渲染的 HTML 會攜帶巨量內嵌 CSS,影響首屏下載 | 使用 CSS Code‑Splitting,搭配 <link rel="preload"> |
忘記在 SSR 端禁用瀏覽器專屬 API(如 window、document) |
SSR 失敗或產生不一致的 HTML | 使用 process.client / process.server 判斷或把相關程式碼搬到 onMounted |
不正確的 key 在 v-for 中 |
Hydration 時 VNode 比對失敗,導致 DOM 重建 | 確保 key 為唯一且穩定的值(如 ID) |
最佳實踐清單
- 先測量:使用 Chrome DevTools 的 Performance 與 Network 面板,觀測
Hydration時間與 JS bundle 大小。 - 分段 Hydration:將首屏與次屏分離,僅 hydrate 首屏。
- 組件懶載入:所有非首屏或非立即交互的組件使用
defineAsyncComponent。 - 使用
v-memo:對於大量不變的列表或計算結果,加入v-memo。 - Streaming + Suspense:在 Node.js 端使用
renderToPipeableStream,搭配<Suspense>延遲渲染重資源組件。 - Cache 初始 state:將伺服器產生的
state壓縮後放在window.__INITIAL_STATE__,在客戶端直接注入而非再次請求。 - 預取關鍵腳本:在 HTML
<head>中加入<link rel="modulepreload">或<link rel="preload">,減少首次載入阻塞。
實際應用場景
| 場景 | 為何需要 Hydration 優化 | 可採用的技巧 |
|---|---|---|
| 電商首頁(大量商品卡片) | 首屏必須快速呈現促銷資訊,且商品列表可能有上千筆 | v-memo + 分段 Hydration(只 hydrate 促銷 Banner) |
| 部落格平台(SEO 為主) | 搜尋引擎需要完整 HTML,使用者則在閱讀時才需要互動 | SSR 完整渲染 + Lazy Hydration(在滾動到文章底部時 hydrate 相關評論區) |
| 資料儀表板(圖表、即時更新) | 圖表套件體積龐大,首次載入不應阻塞 | defineAsyncComponent + Suspense + Streaming,先顯示文字摘要,圖表稍後載入 |
| 社群平台(無限滾動) | 滾動時會不斷載入新內容,若每次都完整 hydrate 會拖慢瀏覽體驗 | IntersectionObserver 結合 Lazy Hydration,僅在元素進入視口時才 hydrate |
總結
- SSR Hydration 是 Vue 3 讓伺服器渲染頁面變為可互動的關鍵步驟,但同時也是效能瓶頸的常見來源。
- 透過 分段 Hydration、Async Component、v-memo、Streaming + Suspense 等技巧,我們可以大幅降低首次 JavaScript 執行量、縮短 Time‑to‑Interactive (TTI),同時保留 SEO 與首屏可見性的優勢。
- 在實務開發中,先測量再優化、避免同步阻塞、合理拆分與延遲載入 是最重要的原則。只要依照本文的最佳實踐與範例逐步調整,即可讓 Vue 3 SSR 應用在效能與使用者體驗上達到雙贏。
祝開發順利,Happy Coding! 🚀