我在 Vue SSR 學到的事(以及為什麼 JSP 早就是 SSR 了)
這篇筆記來自 Things I Learned from SSR 這場演講(「從入門到被開除」——我從 SSR 學到的 N 件事),並結合我自己對 Vue 裡 CSR vs SSR 的一些思考。
兩段話講完基礎
CSR(Vue 3 + Vite 的經典流程):伺服器送出幾乎空白的 HTML 加上 JS bundle,瀏覽器負責建構 DOM。SSR(通常是 Nuxt 3):伺服器執行 Vue、產生完整的 HTML,瀏覽器立刻顯示;接著 Vue 會「接管」那個 DOM 並掛上事件監聽器——這就是 hydration。
從 hydration 延伸出 SSR 的黃金原則:伺服器端與客戶端的程式碼必須產生相同的結果。Date.now()、Math.random(),或任何存取 window 的行為,都是典型的 hydration mismatch 來源——這是最讓人抓狂的 bug 之一。
只會出現在 SSR 裡的 bug
這場演講最有價值的部分,是那些在 CSR 裡根本不存在的問題,而它們都指向同一個根因:伺服器會一直活著;瀏覽器不會。
Memory leak 與 request 污染。 在瀏覽器裡,每次導航都會銷毀狀態。但在伺服器上,同一個 Node instance 要處理數千個 request——如果把 store 定義成全域 singleton,就會被所有使用者共用:狀態會不斷累積,甚至更糟,在並行的 request 之間互相洩漏。解法永遠是 factory pattern:每個 request 都建立一個新的 instance。
// ❌ 全域 singleton——被所有 request 共用
const store = { user: null }
// ✅ factory——每個 request 都是新的 instance
export function createStore() {
return { user: null }
}
相對路徑。 在瀏覽器裡,fetch('/api/data') 能正常運作是因為有 window.location 存在。但在 Node 裡沒有隱含的 origin——axios.get('/api/posts') 會丟出 Invalid URL。你得設定 baseURL,或改用 Nuxt 的 $fetch,它在兩種情境下都會內部自動解析。
await 瀑布。 <script setup> 裡兩個依序執行的 await 會把延遲加總起來(300ms + 200ms = 500ms);用 Promise.all 則會讓它們重疊執行(300ms)。而 useAsyncData + watch 的組合可能會在掛載時觸發重複的 fetch:Suspense 解析了第一次,而剛掛載的 watch 又觸發了第二次。
我在意的那個類比:JSP 早就是這樣了
同時在幾個專案裡用 Spring Boot + JSP,也在另一些專案裡用 Vue,這張表幫我把想法理清楚了:
| JSP + jQuery | Nuxt SSR | |
|---|---|---|
| 誰決定資料的取得順序? | 你自己——命令式 | 框架——宣告式 |
| 會阻塞 render 嗎? | 不會——HTML 早就準備好了 | 會,直到 async setup 解析完成 |
| 局部 render | 天生免費 | 需要巢狀 Suspense |
JSP 的行為其實比較接近 Nuxt SSR,而不是 Vue CSR:HTML 從伺服器送來時就已經渲染好了,JS 是之後才加上去的——這就是 progressive enhancement,雖然 2005 年沒人這樣叫它。從 JSP + Spring Boot 過來的人,其實早就具備 SSR 的直覺;真正改變的是「誰在指揮」(換成框架而不是你自己),以及 hydration 這個新增的成本。
什麼時候該用哪一個
我的操作型結論:內部系統、dashboard 或後台管理 → 用 Vue 3 + Vite 消費 Spring Boot 的 API,走 CSR。電商、landing page,或是對 SEO 至關重要的公開內容 → 用 Nuxt 走 SSR。而如果你已經有 JSP + Spring Boot 在伺服器端 render……你早就已經在 SSR 的陣營裡了;在決定遷移到純 CSR、失去這個優勢之前,先想清楚。