Vue SSR 的實現原理
SSR 是 Server-Side Rendering,伺服器端渲染。它不是「後端寫 Vue」,也不是「不用前端了」。比較精準的說法是:同一套 Vue 組件先在伺服器被渲染成 HTML 字串,送到瀏覽器後,再由客戶端 Vue 接管這份 HTML,讓它變成可互動的應用。
一句話回答:Vue SSR 的核心流程是 server render + client hydration。伺服器用 createSSRApp 和 renderToString 把 Vue app 渲染成 HTML;瀏覽器收到 HTML 先看到內容,再載入客戶端 bundle,用 createSSRApp 對既有 DOM 做 hydration,補上事件監聽與響應式更新能力。
一、 必考觀念
1. CSR、SSR、SSG 差在哪?
先把幾個名詞分清楚:
| 模式 | HTML 產生時機 | 特點 |
|---|---|---|
| CSR | 瀏覽器執行 JS 後產生 | 首屏依賴 JS,互動天然完整 |
| SSR | 每次請求時在伺服器產生 | 首屏快、SEO 好,但伺服器成本較高 |
| SSG | 建置時預先產生 | 適合內容穩定頁面,部署簡單 |
CSR 的流程大概是:
瀏覽器請求頁面
-> server 回傳空殼 HTML + JS
-> browser 下載 JS
-> JS 執行後渲染畫面SSR 的流程是:
瀏覽器請求頁面
-> server 執行 Vue app
-> renderToString 產生 HTML
-> browser 先看到 HTML
-> browser 下載 JS
-> hydration 後變成可互動2. Vue SSR 最小流程
伺服器端:
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const app = createSSRApp({
data: () => ({
message: 'hello ssr',
}),
template: `<div>{{ message }}</div>`,
})
const html = await renderToString(app)renderToString(app) 會把 Vue app 渲染成 HTML 字串:
<div>hello ssr</div>伺服器再把它塞進完整 HTML:
<!DOCTYPE html>
<html>
<head>
<title>Vue SSR</title>
</head>
<body>
<div id="app"><div>hello ssr</div></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>客戶端:
import { createSSRApp } from 'vue'
import App from './App.vue'
const app = createSSRApp(App)
app.mount('#app')SSR 和 CSR 最大不同在於:客戶端不是重新建立一份 DOM,而是 hydrate 伺服器已經產出的 DOM。
3. Hydration 是什麼?
Hydration 可以翻成「水合」或「注水」,但意思其實很直白:
Vue 在瀏覽器端接管伺服器已經渲染好的 HTML,建立對應的組件實例,綁定事件監聽,讓靜態 HTML 變成可互動的應用。
伺服器回來的 HTML 只是靜態內容:
<button>1</button>如果沒有客戶端 Vue,點按鈕不會讓狀態更新。Hydration 之後,Vue 會把這個 DOM 和客戶端產生的 VNode 對上,並補上事件與響應式更新。
簡化流程:
server HTML
-> browser parse DOM
-> client app 產生 VNode
-> Vue 比對 VNode 與現有 DOM
-> 綁定事件與組件狀態
-> 後續更新走正常 patch 流程4. 為什麼 SSR 需要同構程式?
SSR app 通常會分成三個入口:
src/
├─ main.ts 建立 app 的工廠函數
├─ entry-server.ts server render 入口
└─ entry-client.ts client hydrate 入口核心原因是:同一套 App 要在兩個環境跑。
// main.ts
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
export function createApp() {
const app = createSSRApp(App)
const router = createRouter()
const store = createStore()
app.use(router)
app.use(store)
return {
app,
router,
store,
}
}Server entry 負責根據請求 URL render:
// entry-server.ts
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render(url: string) {
const { app, router, store } = createApp()
await router.push(url)
await router.isReady()
const html = await renderToString(app)
return {
html,
state: store.state.value,
}
}Client entry 負責 hydrate:
// entry-client.ts
import { createApp } from './main'
const { app, router, store } = createApp()
store.state.value = window.__INITIAL_STATE__
router.isReady().then(() => {
app.mount('#app')
})這就是同構程式的基本形狀:大部分組件共用,但 server entry 和 client entry 做的事情不同。
二、 SSR 實現流程
1. 接收請求
伺服器收到:
GET /products/1它不能只回固定的 index.html,而是要用這個 URL 建立對應的 Vue app 狀態。
2. 建立全新的 app 實例
每個 request 都應該建立新的 app、router、store。
const { app, router, store } = createApp()這非常重要。不能把 app 或 store 做成全域單例,否則不同使用者請求之間可能互相污染。
這叫 cross-request state pollution。
錯誤概念:
// 不建議:所有 request 共用同一份 store
const store = createStore()正確方向:
// 每次 request 建立新 store
export function createApp() {
const store = createStore()
// ...
}3. 推入目前 URL
SSR 需要根據請求 URL 匹配路由:
await router.push(url)
await router.isReady()這樣 <router-view> 才知道要渲染哪個頁面組件。
如果你還不熟 Vue Router 原理,可以先讀:Vue 路由實現:Hash 路由與 History 路由原理。
4. 資料預取
SSR 最大價值之一,是伺服器能先把頁面需要的資料拿到,再渲染 HTML。
Vue 提供 onServerPrefetch:
<script setup lang="ts">
import { onServerPrefetch, ref } from 'vue'
const product = ref(null)
onServerPrefetch(async () => {
product.value = await fetchProduct()
})
</script>伺服器 render 時會等待這些 server prefetch 完成,再輸出 HTML。
5. Render to String
資料準備好後:
const html = await renderToString(app, ctx)Vue server renderer 會遍歷組件樹與 VNode,產生 HTML 字串。
在 SSR 階段沒有真實 DOM,所以:
- 不會呼叫
onMounted。 - 不會呼叫
onUpdated。 - 不應該直接使用
window、document。 - 大部分 DOM 指令要特別處理。
6. 狀態注水
如果伺服器已經抓過資料,客戶端 hydrate 時不能再從空狀態開始,否則可能產生 hydration mismatch,也會重複請求。
所以 server 會把初始狀態序列化到 HTML:
<script>
window.__INITIAL_STATE__ = {
product: {
id: 1,
name: 'Keyboard'
}
}
</script>Client 啟動時再讀回:
store.state.value = window.__INITIAL_STATE__這個過程常被叫做 state hydration 或狀態注水。
IMPORTANT
真實專案中序列化狀態要注意 XSS,不能直接把任意 JSON 字串拼進 HTML。通常會使用安全序列化工具或框架內建能力。
7. Client Hydration
瀏覽器載入 client bundle 後:
const { app, router, store } = createApp()
store.state.value = window.__INITIAL_STATE__
router.isReady().then(() => {
app.mount('#app')
})Vue 會拿客戶端產生的 VNode 去對應現有 DOM,並接管它。
如果 server 產生的 HTML 和 client 首次 render 結果不同,就會出現 hydration mismatch。
三、 Hydration Mismatch
1. 什麼是 hydration mismatch?
Hydration mismatch 是指:
server 產生的 HTML
!=
client 首次 render 期望的 VNode 結構例如 server render:
<div>server</div>client 首次 render:
<div>client</div>Vue hydrate 時就會發現對不上。
2. 常見原因
| 原因 | 例子 |
|---|---|
| 使用隨機值 | Math.random() |
| 使用時間 | new Date() |
| 依賴瀏覽器環境 | window.innerWidth |
| HTML 結構不合法 | <p><div>hi</div></p> |
| server/client 狀態不同 | client 沒有注入 server state |
| 權限或語系判斷不一致 | server 和 client 使用不同來源 |
3. 如何避免?
做法:
- 保證 server 和 client 初始狀態一致。
- 把隨機數、時間等不穩定資料固定下來並序列化。
- 瀏覽器專屬邏輯放到
onMounted。 - 使用合法 HTML 結構。
- 用狀態注水避免重複抓資料導致首次 render 不一致。
例如:
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const width = ref<number | null>(null)
onMounted(() => {
width.value = window.innerWidth
})
</script>window.innerWidth 只能在 client 使用,所以放在 onMounted 比較安全。
四、 SSR 與生命週期
SSR 沒有真實 DOM,也沒有使用者互動,因此生命週期和 CSR 不完全一樣。
| Hook | SSR 是否執行 | 說明 |
|---|---|---|
setup | 會 | 需要避免直接碰瀏覽器 API |
onServerPrefetch | 會 | 用於 server 資料預取 |
onMounted | 不會 | 只在 client 執行 |
onUpdated | 不會 | SSR 沒有動態 DOM 更新 |
onUnmounted | 不會 | server render 完就結束,不會走 client unmount |
所以在 SSR 中要避免:
setInterval(() => {
// ...
}, 1000)如果這類副作用放在 server 會執行的地方,又沒有 unmount 清理,就可能造成資源泄漏。通常應該放到 onMounted。
五、 Teleport、Directive 與特殊情況
1. Teleport
SSR 中 Teleport 需要特殊處理。主 app 的 render string 不一定直接包含 teleport 內容;它可能會被收集到 SSR context 裡。
const ctx = {}
const html = await renderToString(app, ctx)
console.log(ctx.teleports)最後需要把 teleported markup 插入正確容器。
如果你想延伸理解 Teleport,可以讀:深入 Vue Teleport:跨 DOM 層級的 UI 設計。
2. Custom Directives
很多自訂指令依賴真實 DOM,例如:
mounted(el) {
el.focus()
}SSR 沒有 DOM,所以這類 directive 在 server 端通常不能照原樣執行。
如果需要指定 server render 時要輸出的屬性,可以使用 SSR 專用 hook,例如 getSSRProps。
六、 SSR 的優缺點
優點
- 首屏 HTML 更快出現。
- 對 SEO 和社群分享較友善。
- 弱網或低效能裝置可以先看到內容。
- 路由級資料可以在 server 預先準備。
缺點
- 架構複雜度提高。
- 伺服器成本提高。
- 需要處理 hydration mismatch。
- 第三方套件要確認 SSR 相容。
- 快取、部署、錯誤處理都更複雜。
SSR 不是萬靈丹。後台系統、登入後工具、SEO 不重要的應用,CSR 可能更簡單。
七、 追問題庫
Q1:SSR 是否代表頁面不需要 JavaScript?
不是。
SSR 只代表首屏 HTML 在 server 產生。若頁面需要互動,client 還是要下載 JS 並 hydration。
沒有 hydration 的 SSR 頁面只是靜態 HTML。
Q2:SSR 為什麼 SEO 比 CSR 好?
因為爬蟲或社群平台拿到 HTML 時,內容已經存在於 response 裡,不必等待大量 JS 執行後才看到內容。
但現代搜尋引擎對 JS 支援比以前好,所以 SSR 不是 SEO 的唯一解。它更大的價值通常是首屏體驗、分享預覽與內容可用性。
Q3:為什麼 SSR 每個 request 都要建立新 app?
因為 server 是長時間運行的程序。如果多個使用者共用同一份 app/store,A 使用者的狀態可能污染 B 使用者。
每個 request 建立新 app,可以隔離狀態。
Q4:Hydration 和重新渲染差在哪?
重新渲染是丟掉現有 DOM,再建立新的 DOM。
Hydration 是保留 server 已經產出的 DOM,讓 Vue 建立組件實例並綁定事件,接管這些 DOM。
Q5:SSR 和 SSG 怎麼選?
如果頁面內容在 build time 就能確定,SSG 更簡單,部署成本低。
如果內容高度個人化、每次請求都不同,或需要 request-time 資料,SSR 更合適。
如果是純後台工具,SEO 不重要,CSR 可能就夠。
八、 實作題
題目:說明一個 Vue SSR request 的完整流程
可以這樣回答:
- 瀏覽器請求
/products/1。 - Node server 收到 request。
- server 為這次 request 建立新的 app、router、store。
- router push 到
/products/1並等待路由 ready。 - 頁面組件執行
onServerPrefetch載入產品資料。 renderToString(app, ctx)把組件樹渲染成 HTML。- server 把 HTML、初始狀態、client bundle script 組成完整頁面回傳。
- browser 先顯示 server HTML。
- client bundle 載入,建立同樣的 app,恢復 initial state。
- Vue hydrate 既有 DOM,綁定事件。
- 後續互動回到正常 CSR 更新流程。
九、 資深視角
1. SSR 是架構選型,不只是效能開關
選 SSR 前要問:
- 這個頁面是否需要 SEO?
- 首屏內容是否真的能在 server 端準備?
- 使用者是否登入後才看?
- 資料是否可快取?
- 伺服器成本是否可接受?
- 團隊是否能維護 SSR 相容性?
很多應用只需要 SSG 或 CSR,不一定需要 SSR。
2. 快取策略決定 SSR 成本
SSR 每次 request 都 render,成本會比靜態檔高。實務上通常會搭配:
- CDN cache。
- HTML fragment cache。
- API cache。
- stale-while-revalidate。
- 依路由或使用者狀態分層快取。
真正成熟的 SSR 架構,不只是能 render,而是能控制哪些內容要即時、哪些內容可以快取。
3. Nuxt 解決的是工程化複雜度
手寫 Vue SSR 可以幫你理解原理,但正式產品通常會考慮 Nuxt 這類框架。
Nuxt 幫你處理:
- 路由約定。
- server/client entry。
- 資料載入。
- head 管理。
- hydration。
- server runtime。
- 部署 target。
- 錯誤頁與中介層。
面試時可以說:理解底層原理是為了能排查 hydration、部署、資料一致性問題;實務上則會根據團隊與產品選擇 Nuxt、Vite SSR 或 CSR/SSG。
總結
| 問題 | 面試回答重點 |
|---|---|
| SSR 是什麼 | server 先把 Vue app 渲染成 HTML,client 再 hydrate |
| 核心 API | createSSRApp、renderToString |
| Hydration | 接管 server HTML,綁定事件與響應式更新 |
| 為什麼要狀態注水 | 保持 server/client 首次 render 一致,避免重複請求 |
| 常見問題 | hydration mismatch、瀏覽器 API、跨請求狀態污染 |
| 適用場景 | SEO、首屏、內容頁、可快取的動態頁 |
一句完整的面試回答可以是:
Vue SSR 的流程是伺服器針對每個 request 建立新的 Vue app、router 和 store,根據 URL 匹配路由並預取資料,再用
renderToString把組件樹渲染成 HTML 字串回傳給瀏覽器。瀏覽器先顯示這份 HTML,接著載入 client bundle,用createSSRApp建立同樣的應用並 hydrate 既有 DOM,補上事件和響應式更新。SSR 的難點在於 server/client 初始狀態一致、避免 hydration mismatch、處理瀏覽器專屬 API,以及做好快取與部署。
延伸閱讀
- 前置補充:Vue 組件的 data 為什麼必須是函數
- 前置補充:接口請求一般放在哪個生命週期?為什麼?
- 前置補充:Vue 路由實現:Hash 路由與 History 路由原理
- 效能總覽:Vue 性能優化總覽
- 相關專題:Vue SPA 如何優化首屏載入速度
- 進階補充:Vue 2.x 與 Vue 3.x 渲染器 Diff 算法
- 進階補充:Vue Compiler 的實現原理
- 相關專題:深入 Vue Teleport:跨 DOM 層級的 UI 設計
- 下一篇:Vue 測試策略與可維護性
- Vue 官方文件:Server-Side Rendering
- Vue 官方 API:Server-Side Rendering API
- Vite 官方文件:Server-Side Rendering