跳至主要內容
Skip to content

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 的流程大概是:

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。
  • 不應該直接使用 window、document。
  • 大部分 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
核心 APIcreateSSRApp、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,以及做好快取與部署。

延伸閱讀 ​