跳轉到內容

伺服器端渲染(SSR)

概述

什麼是SSR?

Vue.js是一個用於構建客戶端應用程式的框架。預設情況下,Vue元件在瀏覽器中以輸出形式產生和操作DOM。然而,也可以將相同的元件渲染成HTML字串在伺服器上,直接傳送到瀏覽器,最後將靜態標記在客戶端“水合”成一個完全互動的應用程式。

伺服器渲染的Vue.js應用程式也可以被認為是“同構”或“通用”的,因為您的應用程式的大部分程式碼既在伺服器上執行,也在客戶端上執行。

為什麼使用SSR?

與客戶端單頁應用程式(SPA)相比,SSR的主要優勢在於

  • 更快的內容載入時間:這在網際網路速度慢或裝置速度慢的情況下更為明顯。伺服器渲染的標記不需要等待所有JavaScript下載和執行後才能顯示,因此您的使用者將更快地看到完全渲染的頁面。此外,在首次訪問時,資料獲取是在伺服器端進行的,這可能比客戶端到資料庫的連線速度更快。這通常會導致核心Web Vitals指標有所改善,使用者體驗更好,對於內容載入時間與轉化率直接相關的應用程式來說可能至關重要。

  • 統一的心理模型:您可以使用相同的語言和相同的宣告性、元件導向的心理模型來開發整個應用程式,而不是在後端模板系統和前端框架之間來回跳躍。

  • 更好的SEO:搜尋引擎爬蟲將直接看到完全渲染的頁面。

    提示

    目前,谷歌和必應可以很好地索引同步JavaScript應用程式。關鍵詞是同步。如果您的應用程式以載入旋轉器開始,然後透過Ajax獲取內容,爬蟲將不會等待您完成。這意味著如果您的頁面上有SEO重要內容是非同步獲取的,那麼SSR可能是必要的。

在使用SSR時,還有一些權衡需要考慮

  • 開發限制。特定於瀏覽器的程式碼只能在某些生命週期鉤子內使用;一些外部庫可能需要特殊處理才能在伺服器渲染的應用程式中執行。

  • 更復雜的構建設定和部署需求。與可以部署在任何靜態檔案伺服器上的完全靜態SPA不同,伺服器渲染的應用程式需要一個可以執行Node.js伺服器的環境。

  • 更多的伺服器端負載。在Node.js中渲染整個應用程式比僅提供靜態檔案更耗費CPU資源,因此如果預期流量很大,請準備相應的伺服器負載,並明智地採用快取策略。

在為您的應用使用SSR之前,您首先應該問的問題是,您是否真的需要它。這主要取決於時間到內容對於您的應用有多重要。例如,如果您正在構建一個內部儀表板,初始載入時多幾百毫秒並不會太重要,那麼SSR將是一種過度設計。然而,在時間到內容絕對關鍵的情況下,SSR可以幫助您實現最佳可能的初始載入效能。

SSR 與 SSG

靜態站生成(SSG),也稱為預渲染,是構建快速網站的另一項流行技術。如果用於伺服器端渲染頁面的資料對每個使用者都是相同的,那麼我們可以在請求每次到來時渲染頁面,而是在構建過程中提前渲染一次。預渲染的頁面將生成並作為靜態HTML檔案提供服務。

SSG 保留了 SSR 應用的相同效能特徵:它提供了極佳的時間到內容效能。同時,與 SSR 應用相比,它更便宜且更容易部署,因為輸出是靜態HTML和資產。這裡的重點是 靜態:SSG 只能應用於提供靜態資料(即在構建時已知且在請求之間不會改變的資料)的頁面。每次資料更改,都需要新的部署。

如果您只是調查 SSR 以改善少數營銷頁面(例如 //about/contact 等)的 SEO,那麼您可能需要 SSG 而不是 SSR。SSG 也非常適合文件網站或部落格等基於內容的網站。實際上,您現在正在閱讀的網站就是使用 VitePress 靜態站生成器構建的,它是一個基於 Vue 的靜態站生成器。

基本教程

渲染應用

讓我們看看 Vue SSR 的最基本示例。

  1. 建立一個新的目錄並 cd 進入它
  2. 執行 npm init -y
  3. package.json 中新增 "type": "module",以便 Node.js 以 ES 模組模式 執行。
  4. 執行 npm install vue
  5. 建立一個 example.js 檔案
js
// this runs in Node.js on the server.
import { createSSRApp } from 'vue'
// Vue's server-rendering API is exposed under `vue/server-renderer`.
import { renderToString } from 'vue/server-renderer'

const app = createSSRApp({
  data: () => ({ count: 1 }),
  template: `<button @click="count++">{{ count }}</button>`
})

renderToString(app).then((html) => {
  console.log(html)
})

然後執行

sh
> node example.js

它應該在命令列中列印以下內容

<button>1</button>

renderToString() 接受一個 Vue 應用例項,並返回一個解析為應用渲染的 HTML 的 Promise。也可以使用 Node.js 流 APIWeb 流 API 進行流式渲染。請參閱 SSR API 參考 瞭解詳細資訊。

然後我們可以將 Vue SSR 程式碼移動到伺服器請求處理程式中,該處理程式將應用標記包裹在完整的頁面 HTML 中。我們將使用 express 進行下一步操作

  • 執行 npm install express
  • 建立以下 server.js 檔案
js
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

const server = express()

server.get('/', (req, res) => {
  const app = createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })

  renderToString(app).then((html) => {
    res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>Vue SSR Example</title>
      </head>
      <body>
        <div id="app">${html}</div>
      </body>
    </html>
    `)
  })
})

server.listen(3000, () => {
  console.log('ready')
})

最後,執行 node server.js 並訪問 https://:3000。您應該看到帶有按鈕的頁面正在工作。

在 StackBlitz 上嘗試它

客戶端啟用

如果您點選按鈕,您會注意到數字沒有改變。由於我們沒有在瀏覽器中載入Vue,因此客戶端的HTML是完全靜態的。

為了使客戶端應用程式互動式,Vue需要執行解耦步驟。在解耦過程中,它建立與伺服器上執行相同的Vue應用程式,將每個元件與其應控制的DOM節點匹配,並附加DOM事件監聽器。

要在解耦模式下安裝應用程式,我們需要使用createSSRApp()而不是createApp()

js
// this runs in the browser.
import { createSSRApp } from 'vue'

const app = createSSRApp({
  // ...same app as on server
})

// mounting an SSR app on the client assumes
// the HTML was pre-rendered and will perform
// hydration instead of mounting new DOM nodes.
app.mount('#app')

程式碼結構

注意,我們需要與伺服器上相同的同一應用程式實現。這就是我們需要開始考慮SSR應用程式中的程式碼結構的地方 - 我們如何在伺服器和客戶端之間共享相同的應用程式程式碼?

在這裡,我們將演示最基礎的設定。首先,讓我們將應用程式建立邏輯拆分到一個專門的檔案app.js

js
// app.js (shared between server and client)
import { createSSRApp } from 'vue'

export function createApp() {
  return createSSRApp({
    data: () => ({ count: 1 }),
    template: `<button @click="count++">{{ count }}</button>`
  })
}

該檔案及其依賴項在伺服器和客戶端之間共享 - 我們稱它們為通用程式碼。在編寫通用程式碼時,您需要注意一些事情,我們將在下面討論。

客戶端入口匯入通用程式碼,建立應用程式並執行掛載

js
// client.js
import { createApp } from './app.js'

createApp().mount('#app')

伺服器在請求處理程式中使用相同的建立應用程式邏輯

js
// server.js (irrelevant code omitted)
import { createApp } from './app.js'

server.get('/', (req, res) => {
  const app = createApp()
  renderToString(app).then(html => {
    // ...
  })
})

此外,為了在瀏覽器中載入客戶端檔案,我們還需要

  1. 透過在server.js中新增server.use(express.static('.'))來提供客戶端檔案。
  2. 透過在HTML外殼中新增<script type="module" src="/client.js"></script>來載入客戶端入口
  3. 透過在HTML外殼中新增一個匯入對映來支援在瀏覽器中使用import * from 'vue'

在StackBlitz上嘗試完成的示例。按鈕現在互動式了!

高階解決方案

從示例到生產就緒的SSR應用程式需要更多的工作。我們需要

  • 支援Vue SFC和其他構建步驟要求。實際上,我們需要為同一應用程式協調兩個構建:一個用於客戶端,一個用於伺服器。

    提示

    Vue元件在用於SSR時編譯方式不同 - 模板被編譯成字串連線,而不是虛擬DOM渲染函式,以提高渲染效能。

  • 在伺服器請求處理程式中,使用正確的客戶端資產連結和最佳資源提示渲染HTML。我們可能還需要在SSR和SSG模式之間切換,甚至在同一應用程式中混合兩者。

  • 以通用方式管理路由、資料獲取和狀態管理儲存。

完整的實現相當複雜,並且取決於您選擇的構建工具鏈。因此,我們強烈建議選擇一個更高階、有見地的解決方案,該解決方案可以為您抽象複雜性。以下我們將介紹Vue生態系統中一些推薦的SSR解決方案。

Nuxt

Nuxt 是建立在 Vue 生態系統之上的一個高階框架,它為編寫通用 Vue 應用程式提供了流暢的開發體驗。更好的是,您還可以將其用作靜態網站生成器!我們強烈推薦您嘗試一下。

Quasar

Quasar 是一個完整的基於 Vue 的解決方案,允許您使用一個程式碼庫針對 SPA、SSR、PWA、移動應用、桌面應用和瀏覽器擴充套件。它不僅處理構建設定,還提供了一套符合 Material Design 標準的 UI 元件。

Vite SSR

Vite 提供了對 Vue 伺服器端渲染的內建 支援,但它是有意為之的低階。如果您希望直接使用 Vite,請檢視社群外掛 vite-plugin-ssr,它為您抽象了很多具有挑戰性的細節。

您還可以在這裡找到使用手動設定的 Vue + Vite SSR 專案的示例 (連結),這可以作為構建的基礎。請注意,這僅建議您對 SSR / 構建工具有經驗,並且真的想對高階架構擁有完全的控制權。

編寫 SSR 程式碼

無論您的構建設定或高階框架選擇如何,都有些原則適用於所有 Vue SSR 應用程式。

伺服器上的響應性

在 SSR 過程中,每個請求 URL 都對映到我們應用程式的期望狀態。沒有使用者互動和 DOM 更新,因此在伺服器上不需要響應性。預設情況下,為了更好的效能,響應性在 SSR 期間是停用的。

元件生命週期鉤子

由於沒有動態更新,因此在 SSR 期間將不會呼叫生命週期鉤子,如 mountedonMountedupdatedonUpdated,它們只會在客戶端執行。在 SSR 期間被呼叫的唯一鉤子是 beforeCreatecreated

您應該避免在 beforeCreatesetup()<script setup> 的根作用域中編寫產生需要清理的副作用程式碼。這種副作用的一個例子是使用 setInterval 設定定時器。在客戶端僅程式碼中,我們可以在 beforeUnmountonBeforeUnmountunmountedonUnmounted 中設定並銷燬定時器。然而,由於在 SSR 期間不會呼叫解除安裝鉤子,定時器將永遠存在。為了避免這種情況,請將副作用程式碼移到 mountedonMounted 中。

訪問特定平臺 API

通用程式碼不能假設訪問特定平臺的 API,因此如果您的程式碼直接使用僅瀏覽器全域性變數如 windowdocument,它們將在 Node.js 中執行時丟擲錯誤,反之亦然。

對於在伺服器和客戶端之間共享但具有不同平臺 API 的任務,建議在通用 API 中封裝特定平臺的實現,或者使用為您執行此操作的庫。例如,您可以使用 node-fetch 在伺服器和客戶端上使用相同的 fetch API。

對於僅瀏覽器API,常見的做法是在客戶端生命週期鉤子中延遲訪問它們,例如 mountedonMounted

注意,如果第三方庫沒有考慮到通用使用,那麼將其整合到伺服器端渲染應用程式中可能會很棘手。你可能可以透過模擬一些全域性變數來讓它工作,但這將是 hacky 的,可能會干擾其他庫的環境檢測程式碼。

跨請求狀態汙染

在狀態管理章節中,我們介紹了一種使用 Reactivity API 的簡單狀態管理模式 簡單狀態管理模式。在 SSR 上下文中,這種模式需要一些額外的調整。

該模式在 JavaScript 模組的根作用域中宣告共享狀態。這使得它們成為 單例 - 即在整個應用程式的生命週期中只有一個響應式物件的例項。在純客戶端 Vue 應用程式中,這按預期工作,因為我們的應用程式中的模組在每次瀏覽器頁面訪問時都會全新初始化。

然而,在 SSR 上下文中,應用程式模組通常只在伺服器啟動時初始化一次。相同的模組例項將在多個伺服器請求之間重用,我們的單例狀態物件也是如此。如果我們用針對特定使用者的資料修改共享單例狀態,它可能會意外地洩露到來自另一個使用者的請求。我們稱這種情況為 跨請求狀態汙染

技術上,我們可以在每個請求上重新初始化所有 JavaScript 模組,就像我們在瀏覽器中做的那樣。然而,初始化 JavaScript 模組可能會很昂貴,這將顯著影響伺服器效能。

建議的解決方案是在每個請求上建立整個應用程式的新例項,包括路由和全域性儲存。然後,而不是直接在我們的元件中匯入它,我們透過 應用級提供 提供共享狀態,並在需要它的元件中注入。

js
// app.js (shared between server and client)
import { createSSRApp } from 'vue'
import { createStore } from './store.js'

// called on each request
export function createApp() {
  const app = createSSRApp(/* ... */)
  // create new instance of store per request
  const store = createStore(/* ... */)
  // provide store at the app level
  app.provide('store', store)
  // also expose store for hydration purposes
  return { app, store }
}

如 Pinia 這樣的狀態管理庫正是為此而設計的。有關更多詳細資訊,請參閱 Pinia 的 SSR 指南

渲染不一致

如果預渲染的 HTML 的 DOM 結構與客戶端應用程式的預期輸出不匹配,將出現渲染不一致錯誤。渲染不一致通常由以下原因引起

  1. 模板包含無效的 HTML 巢狀結構,瀏覽器本地的 HTML 解析行為將其“糾正”。例如,一個常見的陷阱是 <div> 不能放在 <p>

    html
    <p><div>hi</div></p>

    如果我們在我們伺服器端渲染的 HTML 中產生這種情況,瀏覽器將在遇到 <div> 時終止第一個 <p>,並將其解析成以下 DOM 結構

    html
    <p></p>
    <div>hi</div>
    <p></p>
  2. 渲染過程中使用的資料包含隨機生成的值。由於同一應用程式將執行兩次 - 一次在伺服器上,一次在客戶端 - 隨機值在這兩次執行之間不一定相同。有兩種方法可以避免隨機值引起的差異

    1. 使用 v-if + onMounted 僅在客戶端渲染依賴於隨機值的部分。您的框架也可能具有內建功能使這更容易,例如 VitePress 中的 <ClientOnly> 元件。

    2. 使用支援使用種子生成隨機數的隨機數生成庫,並保證伺服器執行和客戶端執行使用相同的種子(例如,透過在序列化狀態中包含種子並在客戶端檢索它)。

  3. 伺服器和客戶端位於不同的時區。有時,我們可能需要將時間戳轉換為使用者的本地時間。然而,伺服器執行時的時區和客戶端執行時的時區並不總是相同的,我們在伺服器執行時可能無法可靠地知道使用者的時區。在這種情況下,本地時間轉換也應作為僅客戶端的操作來執行。

當Vue遇到資料恢復不匹配時,它會嘗試自動恢復並調整預渲染的DOM以匹配客戶端狀態。這會導致一些渲染效能損失,因為錯誤的節點被丟棄,新節點被掛載,但在大多數情況下,應用應該能夠按預期繼續工作。儘管如此,最好在開發過程中消除資料恢復不匹配。

抑制資料恢復不匹配

在Vue 3.5+中,可以使用data-allow-mismatch屬性有選擇地抑制不可避免的資料恢復不匹配。

自定義指令

由於大多數自定義指令涉及直接DOM操作,它們在伺服器端渲染期間被忽略。但是,如果您想指定自定義指令應該如何渲染(即它應該新增哪些屬性到渲染的元素),您可以使用getSSRProps指令鉤子。

js
const myDirective = {
  mounted(el, binding) {
    // client-side implementation:
    // directly update the DOM
    el.id = binding.value
  },
  getSSRProps(binding) {
    // server-side implementation:
    // return the props to be rendered.
    // getSSRProps only receives the directive binding.
    return {
      id: binding.value
    }
  }
}

Teleports

Teleports在伺服器端渲染期間需要特殊處理。如果渲染的應用包含Teleports,則Teleports的內容將不會是渲染字串的一部分。一個更簡單的解決方案是在掛載時條件性地渲染Teleport。

如果您確實需要渲染Teleports的內容,它們在ssr上下文物件的teleports屬性下暴露。

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

console.log(ctx.teleports) // { '#teleported': 'teleported content' }

您需要將Teleports的標記注入到最終頁面HTML中的正確位置,就像您需要注入主應用標記一樣。

提示

當使用Teleports和伺服器端渲染一起時,避免針對body - 通常,<body>將包含其他伺服器端渲染的內容,這使得Teleports無法確定水合的正確起始位置。

相反,優先使用專用容器,例如<div id="teleported"></div>,其中只包含Teleports的內容。

伺服器端渲染(SSR)已載入