跳至主要內容
Skip to content

Vue SSR 的實現原理

SSR 是 Server-Side Rendering,伺服器端渲染。它不是「後端寫 Vue」,也不是「不用前端了」。比較精準的說法是:同一套 Vue 組件先在伺服器被渲染成 HTML 字串,送到瀏覽器後,再由客戶端 Vue 接管這份 HTML,讓它變成可互動的應用。

一句話回答:Vue SSR 的核心流程是 server render + client hydration。伺服器用 createSSRApprenderToString 把 Vue app 渲染成 HTML;瀏覽器收到 HTML 先看到內容,再載入客戶端 bundle,用 createSSRApp 對既有 DOM 做 hydration,補上事件監聽與響應式更新能力。


一、 必考觀念

1. CSR、SSR、SSG 差在哪?

先把幾個名詞分清楚:

模式HTML 產生時機特點
CSR瀏覽器執行 JS 後產生首屏依賴 JS,互動天然完整
SSR每次請求時在伺服器產生首屏快、SEO 好,但伺服器成本較高
SSG建置時預先產生適合內容穩定頁面,部署簡單

CSR 的流程大概是:

text
瀏覽器請求頁面
-> server 回傳空殼 HTML + JS
-> browser 下載 JS
-> JS 執行後渲染畫面

SSR 的流程是:

text
瀏覽器請求頁面
-> server 執行 Vue app
-> renderToString 產生 HTML
-> browser 先看到 HTML
-> browser 下載 JS
-> hydration 後變成可互動

2. Vue SSR 最小流程

伺服器端:

typescript
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 字串:

html
<div>hello ssr</div>

伺服器再把它塞進完整 HTML:

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>

客戶端:

typescript
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 只是靜態內容:

html
<button>1</button>

如果沒有客戶端 Vue,點按鈕不會讓狀態更新。Hydration 之後,Vue 會把這個 DOM 和客戶端產生的 VNode 對上,並補上事件與響應式更新。

簡化流程:

text
server HTML
-> browser parse DOM
-> client app 產生 VNode
-> Vue 比對 VNode 與現有 DOM
-> 綁定事件與組件狀態
-> 後續更新走正常 patch 流程

4. 為什麼 SSR 需要同構程式?

SSR app 通常會分成三個入口:

text
src/
├─ main.ts          建立 app 的工廠函數
├─ entry-server.ts  server render 入口
└─ entry-client.ts  client hydrate 入口

核心原因是:同一套 App 要在兩個環境跑。

typescript
// 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:

typescript
// 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:

typescript
// 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. 接收請求

伺服器收到:

text
GET /products/1

它不能只回固定的 index.html,而是要用這個 URL 建立對應的 Vue app 狀態。

2. 建立全新的 app 實例

每個 request 都應該建立新的 app、router、store。

typescript
const { app, router, store } = createApp()

這非常重要。不能把 app 或 store 做成全域單例,否則不同使用者請求之間可能互相污染。

這叫 cross-request state pollution。

錯誤概念:

typescript
// 不建議:所有 request 共用同一份 store
const store = createStore()

正確方向:

typescript
// 每次 request 建立新 store
export function createApp() {
  const store = createStore()
  // ...
}

3. 推入目前 URL

SSR 需要根據請求 URL 匹配路由:

typescript
await router.push(url)
await router.isReady()

這樣 <router-view> 才知道要渲染哪個頁面組件。

如果你還不熟 Vue Router 原理,可以先讀:Vue 路由實現:Hash 路由與 History 路由原理

4. 資料預取

SSR 最大價值之一,是伺服器能先把頁面需要的資料拿到,再渲染 HTML。

Vue 提供 onServerPrefetch

vue
<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

資料準備好後:

typescript
const html = await renderToString(app, ctx)

Vue server renderer 會遍歷組件樹與 VNode,產生 HTML 字串。

在 SSR 階段沒有真實 DOM,所以:

  • 不會呼叫 onMounted
  • 不會呼叫 onUpdated
  • 不應該直接使用 windowdocument
  • 大部分 DOM 指令要特別處理。

6. 狀態注水

如果伺服器已經抓過資料,客戶端 hydrate 時不能再從空狀態開始,否則可能產生 hydration mismatch,也會重複請求。

所以 server 會把初始狀態序列化到 HTML:

html
<script>
  window.__INITIAL_STATE__ = {
    product: {
      id: 1,
      name: 'Keyboard'
    }
  }
</script>

Client 啟動時再讀回:

typescript
store.state.value = window.__INITIAL_STATE__

這個過程常被叫做 state hydration 或狀態注水。

IMPORTANT

真實專案中序列化狀態要注意 XSS,不能直接把任意 JSON 字串拼進 HTML。通常會使用安全序列化工具或框架內建能力。

7. Client Hydration

瀏覽器載入 client bundle 後:

typescript
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 是指:

text
server 產生的 HTML
!=
client 首次 render 期望的 VNode 結構

例如 server render:

html
<div>server</div>

client 首次 render:

html
<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 不一致。

例如:

vue
<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 不完全一樣。

HookSSR 是否執行說明
setup需要避免直接碰瀏覽器 API
onServerPrefetch用於 server 資料預取
onMounted不會只在 client 執行
onUpdated不會SSR 沒有動態 DOM 更新
onUnmounted不會server render 完就結束,不會走 client unmount

所以在 SSR 中要避免:

typescript
setInterval(() => {
  // ...
}, 1000)

如果這類副作用放在 server 會執行的地方,又沒有 unmount 清理,就可能造成資源泄漏。通常應該放到 onMounted


五、 Teleport、Directive 與特殊情況

1. Teleport

SSR 中 Teleport 需要特殊處理。主 app 的 render string 不一定直接包含 teleport 內容;它可能會被收集到 SSR context 裡。

typescript
const ctx = {}
const html = await renderToString(app, ctx)

console.log(ctx.teleports)

最後需要把 teleported markup 插入正確容器。

如果你想延伸理解 Teleport,可以讀:深入 Vue Teleport:跨 DOM 層級的 UI 設計

2. Custom Directives

很多自訂指令依賴真實 DOM,例如:

typescript
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 的完整流程

可以這樣回答:

  1. 瀏覽器請求 /products/1
  2. Node server 收到 request。
  3. server 為這次 request 建立新的 app、router、store。
  4. router push 到 /products/1 並等待路由 ready。
  5. 頁面組件執行 onServerPrefetch 載入產品資料。
  6. renderToString(app, ctx) 把組件樹渲染成 HTML。
  7. server 把 HTML、初始狀態、client bundle script 組成完整頁面回傳。
  8. browser 先顯示 server HTML。
  9. client bundle 載入,建立同樣的 app,恢復 initial state。
  10. Vue hydrate 既有 DOM,綁定事件。
  11. 後續互動回到正常 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
核心 APIcreateSSRApprenderToString
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,以及做好快取與部署。

延伸閱讀