什么是服務器端渲染(SSR)?
Vue.js 是構建客戶端應用程序的框架。默認情況下,可以在瀏覽器中輸出 Vue 組件,進行生成 DOM 和操作 DOM。然而,也可以將同一個組件渲染為服務器端的 HTML 字符串,將它們直接發送到瀏覽器,最后將這些靜態標記"激活"為客戶端上完全可交互的應用程序。
服務器渲染的 Vue.js 應用程序也可以被認為是"同構"或"通用",因為應用程序的大部分代碼都可以在服務器和客戶端上運行。
為什么使用服務器端渲染(SSR)?
優勢:
1.更好的 SEO,由於搜索引擎爬蟲抓取工具可以直接查看完全渲染的頁面。
2.更快的內容到達時間(time-to-content),特別是對於緩慢的網絡情況或運行緩慢的設備。無需等待所有的 JavaScript 都完成下載並執行,才顯示服務器渲染的標記,所以你的用戶將會更快速地看到完整渲染的頁面。通常可以產生更好的用戶體驗,並且對於那些「內容到達時間(time-to-content)與轉化率直接相關」的應用程序而言,服務器端渲染(SSR)至關重要。
使用服務器端渲染(SSR)時還需要有一些權衡之處:
-
開發條件所限。瀏覽器特定的代碼,只能在某些生命周期鈎子函數(lifecycle hook)中使用;一些外部擴展庫(external library)可能需要特殊處理,才能在服務器渲染應用程序中運行。
-
涉及構建設置和部署的更多要求。與可以部署在任何靜態文件服務器上的完全靜態單頁面應用程序(SPA)不同,服務器渲染應用程序,需要處於 Node.js server 運行環境。
-
更多的服務器端負載。在 Node.js 中渲染完整的應用程序,顯然會比僅僅提供靜態文件的 server 更加大量占用 CPU 資源(CPU-intensive - CPU 密集),因此如果你預料在高流量環境(high traffic)下使用,請准備相應的服務器負載,並明智地采用緩存策略。
服務器端渲染 vs 預渲染(SSR vs Prerendering)
如果你調研服務器端渲染(SSR)只是用來改善少數營銷頁面(例如 /
, /about
, /contact
等)的 SEO,那么你可能需要預渲染。無需使用 web 服務器實時動態編譯 HTML,而是使用預渲染方式,在構建時(build time)簡單地生成針對特定路由的靜態 HTML 文件。優點是設置預渲染更簡單,並可以將你的前端作為一個完全靜態的站點。。
安裝:
npm install vue vue-server-renderer --save
注意
- 推薦使用 Node.js 版本 6+。
vue-server-renderer
和vue
必須匹配版本。vue-server-renderer
依賴一些 Node.js 原生模塊,因此只能在 Node.js 中使用。我們可能會提供一個更簡單的構建,可以在將來在其他「JavaScript 運行時(runtime)」運行。
#渲染一個 Vue 實例
// 第 1 步:創建一個 Vue 實例 const Vue = require('vue') const app = new Vue({ template: `<div>Hello World</div>` }) // 第 2 步:創建一個 renderer const renderer = require('vue-server-renderer').createRenderer() // 第 3 步:將 Vue 實例渲染為 HTML renderer.renderToString(app, (err, html) => { if (err) throw err console.log(html) // => <div data-server-rendered="true">Hello World</div> }) // 在 2.5.0+,如果沒有傳入回調函數,則會返回 Promise: renderer.renderToString(app).then(html => { console.log(html) }).catch(err => { console.error(err) })
與服務器集成
npm install express --save
const Vue = require('vue') const server = require('express')() const renderer = require('vue-server-renderer').createRenderer() server.get('*', (req, res) => { const app = new Vue({ data: { url: req.url }, template: `<div>訪問的 URL 是: {{ url }}</div>` }) renderer.renderToString(app, (err, html) => { if (err) { res.status(500).end('Internal Server Error') return } res.end(` <!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body>${html}</body> </html> `) }) }) server.listen(8080)
使用一個頁面模板
當你在渲染 Vue 應用程序時,renderer 只從應用程序生成 HTML 標記(markup)。在這個示例中,我們必須用一個額外的 HTML 頁面包裹容器,來包裹生成的 HTML 標記。
為了簡化這些,你可以直接在創建 renderer 時提供一個頁面模板。多數時候,我們會將頁面模板放在特有的文件中,例如 index.template.html
:
<!DOCTYPE html> <html lang="en"> <head><title>Hello</title></head> <body> <!--vue-ssr-outlet--> </body> </html>
注意 <!--vue-ssr-outlet-->
注釋 -- 這里將是應用程序 HTML 標記注入的地方。
然后,我們可以讀取和傳輸文件到 Vue renderer 中:
const renderer = createRenderer({ template: require('fs').readFileSync('./index.template.html', 'utf-8') }) renderer.renderToString(app, (err, html) => { console.log(html) // html 將是注入應用程序內容的完整頁面 })
#模板插值
模板還支持簡單插值。給定如下模板:
<html> <head> <!-- 使用雙花括號(double-mustache)進行 HTML 轉義插值(HTML-escaped interpolation) --> <title>{{ title }}</title> <!-- 使用三花括號(triple-mustache)進行 HTML 不轉義插值(non-HTML-escaped interpolation) --> {{{ meta }}} </head> <body> <!--vue-ssr-outlet--> </body> </html>
我們可以通過傳入一個"渲染上下文對象",作為 renderToString
函數的第二個參數,來提供插值數據:
const context = { title: 'hello', meta: ` <meta ...> <meta ...> ` } renderer.renderToString(app, context, (err, html) => { // 頁面 title 將會是 "Hello" // meta 標簽也會注入 })
也可以與 Vue 應用程序實例共享 context
對象,允許模板插值中的組件動態地注冊數據。
此外,模板支持一些高級特性,例如:
- 在使用
*.vue
組件時,自動注入「關鍵的 CSS(critical CSS)」; - 在使用
clientManifest
時,自動注入「資源鏈接(asset links)和資源預加載提示(resource hints)」; - 在嵌入 Vuex 狀態進行客戶端融合(client-side hydration)時,自動注入以及 XSS 防御。
在之后的指南中介紹相關概念時,我們將詳細討論這些。
編寫通用代碼
在進一步介紹之前,讓我們花點時間來討論編寫"通用"代碼時的約束條件 - 即運行在服務器和客戶端的代碼。由於用例和平台 API 的差異,當運行在不同環境中時,我們的代碼將不會完全相同。所以這里我們將會闡述你需要理解的關鍵事項。
服務器上的數據響應
在純客戶端應用程序(client-only app)中,每個用戶會在他們各自的瀏覽器中使用新的應用程序實例。對於服務器端渲染,我們也希望如此:每個請求應該都是全新的、獨立的應用程序實例,以便不會有交叉請求造成的狀態污染(cross-request state pollution)。
因為實際的渲染過程需要確定性,所以我們也將在服務器上“預取”數據("pre-fetching" data) - 這意味着在我們開始渲染時,我們的應用程序就已經解析完成其狀態。也就是說,將數據進行響應式的過程在服務器上是多余的,所以默認情況下禁用。禁用響應式數據,還可以避免將「數據」轉換為「響應式對象」的性能開銷
組件生命周期鈎子函數
由於沒有動態更新,所有的生命周期鈎子函數中,只有 beforeCreate
和 created
會在服務器端渲染(SSR)過程中被調用。這就是說任何其他生命周期鈎子函數中的代碼(例如 beforeMount
或 mounted
),只會在客戶端執行。
此外還需要注意的是,你應該避免在 beforeCreate
和 created
生命周期時產生全局副作用的代碼,例如在其中使用 setInterval
設置 timer。在純客戶端(client-side only)的代碼中,我們可以設置一個 timer,然后在 beforeDestroy
或 destroyed
生命周期時將其銷毀。但是,由於在 SSR 期間並不會調用銷毀鈎子函數,所以 timer 將永遠保留下來。為了避免這種情況,請將副作用代碼移動到 beforeMount
或 mounted
生命周期中。
訪問特定平台(Platform-Specific) API
用代碼不可接受特定平台的 API,因此如果你的代碼中,直接使用了像 window
或 document
,這種僅瀏覽器可用的全局變量,則會在 Node.js 中執行時拋出錯誤,反之也是如此。
對於共享於服務器和客戶端,但用於不同平台 API 的任務(task),建議將平台特定實現包含在通用 API 中,或者使用為你執行此操作的 library。例如,axios 是一個 HTTP 客戶端,可以向服務器和客戶端都暴露相同的 API。
對於僅瀏覽器可用的 API,通常方式是,在「純客戶端(client-only)」的生命周期鈎子函數中惰性訪問(lazily access)它們。
請注意,考慮到如果第三方 library 不是以上面的通用用法編寫,則將其集成到服務器渲染的應用程序中,可能會很棘手。你可能要通過模擬(mock)一些全局變量來使其正常運行,但這只是 hack 的做法,並且可能會干擾到其他 library 的環境檢測代碼
自定義指令
大多數自定義指令直接操作 DOM,因此會在服務器端渲染(SSR)過程中導致錯誤。有兩種方法可以解決這個問題:
-
推薦使用組件作為抽象機制,並運行在「虛擬 DOM 層級(Virtual-DOM level)」(例如,使用渲染函數(render function))。
-
如果你有一個自定義指令,但是不是很容易替換為組件,則可以在創建服務器 renderer 時,使用
directives
選項所提供"服務器端版本(server-side version)"。
源碼結構
#避免狀態單例
當編寫純客戶端(client-only)代碼時,我們習慣於每次在新的上下文中對代碼進行取值。但是,Node.js 服務器是一個長期運行的進程。當我們的代碼進入該進程時,它將進行一次取值並留存在內存中。這意味着如果創建一個單例對象,它將在每個傳入的請求之間共享。
如基本示例所示,我們為每個請求創建一個新的根 Vue 實例。這與每個用戶在自己的瀏覽器中使用新應用程序的實例類似。如果我們在多個請求之間使用一個共享的實例,很容易導致交叉請求狀態污染(cross-request state pollution)。
因此,我們不應該直接創建一個應用程序實例,而是應該暴露一個可以重復執行的工廠函數,為每個請求創建新的應用程序實例:
// app.js const Vue = require('vue') module.exports = function createApp (context) { return new Vue({ data: { url: context.url }, template: `<div>訪問的 URL 是: {{ url }}</div>` }) }
並且我們的服務器代碼現在變為:
// server.js const createApp = require('./app') server.get('*', (req, res) => { const context = { url: req.url } const app = createApp(context) renderer.renderToString(app, (err, html) => { // 處理錯誤…… res.end(html) }) })
同樣的規則也適用於 router、store 和 event bus 實例。你不應該直接從模塊導出並將其導入到應用程序中,而是需要在 createApp
中創建一個新的實例,並從根 Vue 實例注入。
在使用帶有
{ runInNewContext: true }
的 bundle renderer 時,可以消除此約束,但是由於需要為每個請求創建一個新的 vm 上下文,因此伴隨有一些顯著性能開銷。
介紹構建步驟
目前為止,我們還沒有討論過如何將相同的 Vue 應用程序提供給客戶端。為了做到這一點,我們需要使用 webpack 來打包我們的 Vue 應用程序。事實上,我們可能需要在服務器上使用 webpack 打包 Vue 應用程序,因為:
-
通常 Vue 應用程序是由 webpack 和
vue-loader
構建,並且許多 webpack 特定功能不能直接在 Node.js 中運行(例如通過file-loader
導入文件,通過css-loader
導入 CSS)。 -
盡管 Node.js 最新版本能夠完全支持 ES2015 特性,我們還是需要轉譯客戶端代碼以適應老版瀏覽器。這也會涉及到構建步驟。
所以基本看法是,對於客戶端應用程序和服務器應用程序,我們都要使用 webpack 打包 - 服務器需要「服務器 bundle」然后用於服務器端渲染(SSR),而「客戶端 bundle」會發送給瀏覽器,用於混合靜態標記。
使用 webpack 的源碼結構
src
├── components
│ ├── Foo.vue
│ ├── Bar.vue
│ └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry) ├── entry-client.js # 僅運行於瀏覽器 └── entry-server.js # 僅運行於服務器
app.js
app.js
是我們應用程序的「通用 entry」。在純客戶端應用程序中,我們將在此文件中創建根 Vue 實例,並直接掛載到 DOM。但是,對於服務器端渲染(SSR),責任轉移到純客戶端 entry 文件。app.js
簡單地使用 export 導出一個 createApp
函數:
import Vue from 'vue' import App from './App.vue' // 導出一個工廠函數,用於創建新的 // 應用程序、router 和 store 實例 export function createApp () { const app = new Vue({ // 根實例簡單的渲染應用程序組件。 render: h => h(App) }) return { app } }
entry-client.js
:
客戶端 entry 只需創建應用程序,並且將其掛載到 DOM 中:
import { createApp } from './app' // 客戶端特定引導邏輯…… const { app } = createApp() // 這里假定 App.vue 模板中根元素具有 `id="app"` app.$mount('#app')
entry-server.js
:
服務器 entry 使用 default export 導出函數,並在每次渲染中重復調用此函數。此時,除了創建和返回應用程序實例之外,它不會做太多事情 - 但是稍后我們將在此執行服務器端路由匹配(server-side route matching)和數據預取邏輯(data pre-fetching logic)。
import { createApp } from './app' export default context => { const { app } = createApp() return app }
路由和代碼分割
#使用 vue-router
的路
可能已經注意到,我們的服務器代碼使用了一個 *
處理程序,它接受任意 URL。這允許我們將訪問的 URL 傳遞到我們的 Vue 應用程序中,然后對客戶端和服務器復用相同的路由配置!
為此,建議使用官方提供的 vue-router
。我們首先創建一個文件,在其中創建 router。注意,類似於 createApp
,我們也需要給每個請求一個新的 router 實例,所以文件導出一個 createRouter
函數:
// router.js import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) export function createRouter () { return new Router({ mode: 'history', routes: [ // ... ] }) }
然后更新 app.js
:
// app.js import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' export function createApp () { // 創建 router 實例 const router = createRouter() const app = new Vue({ // 注入 router 到根 Vue 實例 router, render: h => h(App) }) // 返回 app 和 router return { app, router } }
現在我們需要在 entry-server.js
中實現服務器端路由邏輯(server-side routing logic):
// entry-server.js import { createApp } from './app' export default context => { // 因為有可能會是異步路由鈎子函數或組件,所以我們將返回一個 Promise, // 以便服務器能夠等待所有的內容在渲染前, // 就已經准備就緒。 return new Promise((resolve, reject) => { const { app, router } = createApp() // 設置服務器端 router 的位置 router.push(context.url) // 等到 router 將可能的異步組件和鈎子函數解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents() // 匹配不到的路由,執行 reject 函數,並返回 404 if (!matchedComponents.length) { return reject({ code: 404 }) } // Promise 應該 resolve 應用程序實例,以便它可以渲染 resolve(app) }, reject) }) }
假設服務器 bundle 已經完成構建(請再次忽略現在的構建設置),服務器用法看起來如下:
// server.js const createApp = require('/path/to/built-server-bundle.js') server.get('*', (req, res) => { const context = { url: req.url } createApp(context).then(app => { renderer.renderToString(app, (err, html) => { if (err) { if (err.code === 404) { res.status(404).end('Page not found') } else { res.status(500).end('Internal Server Error') } } else { res.end(html) } }) }) })
代碼分割
用程序的代碼分割或惰性加載,有助於減少瀏覽器在初始渲染中下載的資源體積,可以極大地改善大體積 bundle 的可交互時間(TTI - time-to-interactive)。這里的關鍵在於,對初始首屏而言,"只加載所需"。
Vue 提供異步組件作為第一類的概念,將其與 webpack 2 所支持的使用動態導入作為代碼分割點相結合,你需要做的是:
// 這里進行修改…… import Foo from './Foo.vue' // 改為這樣: const Foo = () => import('./Foo.vue')
在 Vue 2.5 以下的版本中,服務端渲染時異步組件只能用在路由組件上。然而在 2.5+ 的版本中,得益於核心算法的升級,異步組件現在可以在應用中的任何地方使用。
需要注意的是,你仍然需要在掛載 app 之前調用 router.onReady
,因為路由器必須要提前解析路由配置中的異步組件,才能正確地調用組件中可能存在的路由鈎子。這一步我們已經在我們的服務器入口(server entry)中實現過了,現在我們只需要更新客戶端入口(client entry):
// entry-client.js import { createApp } from './app' const { app, router } = createApp() router.onReady(() => { app.$mount('#app') })
異步路由組件的路由配置示例:
// router.js import Vue from 'vue' import Router from 'vue-router' Vue.use(Router) export function createRouter () { return new Router({ mode: 'history', routes: [ { path: '/', component: () => import('./components/Home.vue') }, { path: '/item/:id', component: () => import('./components/Item.vue') } ] }) }
數據預取和狀態
#數據預取存儲容器(Data Store)
在服務器端渲染(SSR)期間,我們本質上是在渲染我們應用程序的"快照",所以如果應用程序依賴於一些異步數據,那么在開始渲染過程之前,需要先預取和解析好這些數據。
另一個需要關注的問題是在客戶端,在掛載(mount)到客戶端應用程序之前,需要獲取到與服務器端應用程序完全相同的數據 - 否則,客戶端應用程序會因為使用與服務器端應用程序不同的狀態,然后導致混合失敗。
為了解決這個問題,獲取的數據需要位於視圖組件之外,即放置在專門的數據預取存儲容器(data store)或"狀態容器(state container))"中。首先,在服務器端,我們可以在渲染之前預取數據,並將數據填充到 store 中。此外,我們將在 HTML 中序列化(serialize)和內聯預置(inline)狀態。這樣,在掛載(mount)到客戶端應用程序之前,可以直接從 store 獲取到內聯預置(inline)狀態。
為此,我們將使用官方狀態管理庫 Vuex。我們先創建一個 store.js
文件,里面會模擬一些根據 id 獲取 item 的邏輯:
// store.js import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) // 假定我們有一個可以返回 Promise 的 // 通用 API(請忽略此 API 具體實現細節) import { fetchItem } from './api' export function createStore () { return new Vuex.Store({ state: { items: {} }, actions: { fetchItem ({ commit }, id) { // `store.dispatch()` 會返回 Promise, // 以便我們能夠知道數據在何時更新 return fetchItem(id).then(item => { commit('setItem', { id, item }) }) } }, mutations: { setItem (state, { id, item }) { Vue.set(state.items, id, item) } } }) }
然后修改 app.js
:
// app.js import Vue from 'vue' import App from './App.vue' import { createRouter } from './router' import { createStore } from './store' import { sync } from 'vuex-router-sync' export function createApp () { // 創建 router 和 store 實例 const router = createRouter() const store = createStore() // 同步路由狀態(route state)到 store sync(store, router) // 創建應用程序實例,將 router 和 store 注入 const app = new Vue({ router, store, render: h => h(App) }) // 暴露 app, router 和 store。 return { app, router, store } }
帶有邏輯配置的組件(Logic Collocation with Components)
那么,我們在哪里放置「dispatch 數據預取 action」的代碼?
我們需要通過訪問路由,來決定獲取哪部分數據 - 這也決定了哪些組件需要渲染。事實上,給定路由所需的數據,也是在該路由上渲染組件時所需的數據。所以在路由組件中放置數據預取邏輯,是很自然的事情。
我們將在路由組件上暴露出一個自定義靜態函數 asyncData
。注意,由於此函數會在組件實例化之前調用,所以它無法訪問 this
。需要將 store 和路由信息作為參數傳遞進去:
<!-- Item.vue --> <template> <div>{{ item.title }}</div> </template> <script> export default { asyncData ({ store, route }) { // 觸發 action 后,會返回 Promise return store.dispatch('fetchItem', route.params.id) }, computed: { // 從 store 的 state 對象中的獲取 item。 item () { return this.$store.state.items[this.$route.params.id] } } } </script>
服務器端數據預取(Server Data Fetching)
在 entry-server.js
中,我們可以通過路由獲得與 router.getMatchedComponents()
相匹配的組件,如果組件暴露出 asyncData
,我們就調用這個方法。然后我們需要將解析完成的狀態,附加到渲染上下文(render context)中。
// entry-server.js import { createApp } from './app' export default context => { return new Promise((resolve, reject) => { const { app, router, store } = createApp() router.push(context.url) router.onReady(() => { const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) { return reject({ code: 404 }) } // 對所有匹配的路由組件調用 `asyncData()` Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }) } })).then(() => { // 在所有預取鈎子(preFetch hook) resolve 后, // 我們的 store 現在已經填充入渲染應用程序所需的狀態。 // 當我們將狀態附加到上下文, // 並且 `template` 選項用於 renderer 時, // 狀態將自動序列化為 `window.__INITIAL_STATE__`,並注入 HTML。 context.state = store.state resolve(app) }).catch(reject) }, reject) }) }
當使用 template
時,context.state
將作為 window.__INITIAL_STATE__
狀態,自動嵌入到最終的 HTML 中。而在客戶端,在掛載到應用程序之前,store 就應該獲取到狀態:
// entry-client.js const { app, router, store } = createApp() if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__) }
客戶端數據預取(Client Data Fetching)
在客戶端,處理數據預取有兩種不同方式:
- 在路由導航之前解析數據:
使用此策略,應用程序會等待視圖所需數據全部解析之后,再傳入數據並處理當前視圖。好處在於,可以直接在數據准備就緒時,傳入視圖渲染完整內容,但是如果數據預取需要很長時間,用戶在當前視圖會感受到"明顯卡頓"。因此,如果使用此策略,建議提供一個數據加載指示器(data loading indicator)。
我們可以通過檢查匹配的組件,並在全局路由鈎子函數中執行 asyncData
函數,來在客戶端實現此策略。注意,在初始路由准備就緒之后,我們應該注冊此鈎子,這樣我們就不必再次獲取服務器提取的數據。
// entry-client.js // ...忽略無關代碼 router.onReady(() => { // 添加路由鈎子函數,用於處理 asyncData. // 在初始路由 resolve 后執行, // 以便我們不會二次預取(double-fetch)已有的數據。 // 使用 `router.beforeResolve()`,以便確保所有異步組件都 resolve。 router.beforeResolve((to, from, next) => { const matched = router.getMatchedComponents(to) const prevMatched = router.getMatchedComponents(from) // 我們只關心非預渲染的組件 // 所以我們對比它們,找出兩個匹配列表的差異組件 let diffed = false const activated = matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) }) if (!activated.length) { return next() } // 這里如果有加載指示器(loading indicator),就觸發 Promise.all(activated.map(c => { if (c.asyncData) { return c.asyncData({ store, route: to }) } })).then(() => { // 停止加載指示器(loading indicator) next() }).catch(next) }) app.$mount('#app') })
- 匹配要渲染的視圖后,再獲取數據:
此策略將客戶端數據預取邏輯,放在視圖組件的 beforeMount
函數中。當路由導航被觸發時,可以立即切換視圖,因此應用程序具有更快的響應速度。然而,傳入視圖在渲染時不會有完整的可用數據。因此,對於使用此策略的每個視圖組件,都需要具有條件加載狀態。
這可以通過純客戶端(client-only)的全局 mixin 來實現:
Vue.mixin({ beforeMount () { const { asyncData } = this.$options if (asyncData) { // 將獲取數據操作分配給 promise // 以便在組件中,我們可以在數據准備就緒后 // 通過運行 `this.dataPromise.then(...)` 來執行其他任務 this.dataPromise = asyncData({ store: this.$store, route: this.$route }) } } })
這兩種策略是根本上不同的用戶體驗決策,應該根據你創建的應用程序的實際使用場景進行挑選。但是無論你選擇哪種策略,當路由組件重用(同一路由,但是 params 或 query 已更改,例如,從 user/1
到 user/2
)時,也應該調用 asyncData
函數。我們也可以通過純客戶端(client-only)的全局 mixin 來處理這個問題:
Vue.mixin({ beforeRouteUpdate (to, from, next) { const { asyncData } = this.$options if (asyncData) { asyncData({ store: this.$store, route: to }).then(next).catch(next) } else { next() } } })
#Store 代碼拆分(Store Code Splitting)
在大型應用程序中,我們的 Vuex store 可能會分為多個模塊。當然,也可以將這些模塊代碼,分割到相應的路由組件 chunk 中。假設我們有以下 store 模塊:
// store/modules/foo.js export default { namespaced: true, // 重要信息:state 必須是一個函數, // 因此可以創建多個實例化該模塊 state: () => ({ count: 0 }), actions: { inc: ({ commit }) => commit('inc') }, mutations: { inc: state => state.count++ } }
我們可以在路由組件的 asyncData
鈎子函數中,使用 store.registerModule
惰性注冊(lazy-register)這個模塊:
// 在路由組件內
<template> <div>{{ fooCount }}</div> </template> <script> // 在這里導入模塊,而不是在 `store/index.js` 中 import fooStoreModule from '../store/modules/foo' export default { asyncData ({ store }) { store.registerModule('foo', fooStoreModule) return store.dispatch('foo/inc') }, // 重要信息:當多次訪問路由時, // 避免在客戶端重復注冊模塊。 destroyed () { this.$store.unregisterModule('foo') }, computed: { fooCount () { return this.$store.state.foo.count } } } </script>
由於模塊現在是路由組件的依賴,所以它將被 webpack 移動到路由組件的異步 chunk 中。
客戶端激活(client-side hydration)
所謂客戶端激活,指的是 Vue 在瀏覽器端接管由服務端發送的靜態 HTML,使其變為由 Vue 管理的動態 DOM 的過程。
在 entry-client.js
中,我們用下面這行掛載(mount)應用程序:
// 這里假定 App.vue template 根元素的 `id="app"` app.$mount('#app')
由於服務器已經渲染好了 HTML,我們顯然無需將其丟棄再重新創建所有的 DOM 元素。相反,我們需要"激活"這些靜態的 HTML,然后使他們成為動態的(能夠響應后續的數據變化)。
如果你檢查服務器渲染的輸出結果,你會注意到應用程序的根元素上添加了一個特殊的屬性:
<div id="app" data-server-rendered="true">
data-server-rendered
特殊屬性,讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,並且應該以激活模式進行掛載。注意,這里並沒有添加 id="app"
,而是添加 data-server-rendered
屬性:你需要自行添加 ID 或其他能夠選取到應用程序根元素的選擇器,否則應用程序將無法正常激活。
注意,在沒有 data-server-rendered
屬性的元素上,還可以向 $mount
函數的 hydrating
參數位置傳入 true
,來強制使用激活模式(hydration):
// 強制使用應用程序的激活模式 app.$mount('#app', true)
在開發模式下,Vue 將推斷客戶端生成的虛擬 DOM 樹(virtual DOM tree),是否與從服務器渲染的 DOM 結構(DOM structure)匹配。如果無法匹配,它將退出混合模式,丟棄現有的 DOM 並從頭開始渲染。在生產模式下,此檢測會被跳過,以避免性能損耗。
#一些需要注意的坑
使用「SSR + 客戶端混合」時,需要了解的一件事是,瀏覽器可能會更改的一些特殊的 HTML 結構。例如,當你在 Vue 模板中寫入:
<table> <tr><td>hi</td></tr> </table>
瀏覽器會在 <table>
內部自動注入 <tbody>
,然而,由於 Vue 生成的虛擬 DOM(virtual DOM) 不包含 <tbody>
,所以會導致無法匹配。為能夠正確匹配,請確保在模板中寫入有效的 HTML。
使用基本 SSR 的問題
const createApp = require('/path/to/built-server-bundle.js')
這是理所應當的,然而在每次編輯過應用程序源代碼之后,都必須停止並重啟服務。這在開發過程中會影響開發效率。此外,Node.js 本身不支持 source map。
#傳入 BundleRenderer
vue-server-renderer
提供一個名為 createBundleRenderer
的 API,用於處理此問題,通過使用 webpack 的自定義插件,server bundle 將生成為可傳遞到 bundle renderer 的特殊 JSON 文件。所創建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下優點:
-
內置的 source map 支持(在 webpack 配置中使用
devtool: 'source-map'
) -
在開發環境甚至部署過程中熱重載(通過讀取更新后的 bundle,然后重新創建 renderer 實例)
-
關鍵 CSS(critical CSS) 注入(在使用
*.vue
文件時):自動內聯在渲染過程中用到的組件所需的CSS。更多細節請查看 CSS 章節。 -
使用 clientManifest 進行資源注入:自動推斷出最佳的預加載(preload)和預取(prefetch)指令,以及初始渲染所需的代碼分割 chunk。
在下一章節中,我們將討論如何配置 webpack,以生成 bundle renderer 所需的構建工件(build artifact),但現在假設我們已經有了這些需要的構建工件,以下就是創建和使用 bundle renderer 的方法:
const { createBundleRenderer } = require('vue-server-renderer') const renderer = createBundleRenderer(serverBundle, { runInNewContext: false, // 推薦 template, // (可選)頁面模板 clientManifest // (可選)客戶端構建 manifest }) // 在服務器處理函數中…… server.get('*', (req, res) => { const context = { url: req.url } // 這里無需傳入一個應用程序,因為在執行 bundle 時已經自動創建過。 // 現在我們的服務器與應用程序已經解耦! renderer.renderToString(context, (err, html) => { // 處理異常…… res.end(html) }) })
bundle renderer 在調用 renderToString
時,它將自動執行「由 bundle 創建的應用程序實例」所導出的函數(傳入上下文
作為參數),然后渲染它。
注意,推薦將 runInNewContext
選項設置為 false
或 'once'
。