什麼是服務端渲染(SSR)?
SSR(Server-Side Rendering),在SPA(Single-Page Application)出現之前,網頁就是在服務端渲染的。伺服器接收到客戶端請求後,將資料和模板拼接成完整的頁面響應到客戶端,客戶端將響應結果渲染出來。如果使用者需要瀏覽新的頁面,則需要重複這個過程。隨著Angular、React和Vue的興起,SPA開始流行,單頁面應用可以在不過載整個頁面的情況下,通過ajax和伺服器進行互動,高效更新部分頁面,這無疑帶來了良好的使用者體驗。然而,對於需要SEO、追求首屏速度的頁面,使用SPA是糟糕的。如果我們想使用Vue,又需要考慮到SEO、首屏渲染速度,那該怎麼辦?好在Vue是支援服務端渲染的,接下來我們主要說的是Vue的服務端渲染。
Vue SSR適用場景及解決的問題
我們主要在管理後臺系統和內嵌H5電商頁中使用Vue,對於管理後臺系統,不需要考慮SEO和首屏渲染時間,所以是否用SPA的方式其實問題不大。而對於電商頁,雖然不需要SEO,但是首屏渲染變得十分重要。一般的SPA頁面開啟時,HTML大體的結構如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/app.js"></script>
</body>
</html>
複製程式碼
這種情況下,HTML和JS載入成功後通過JS再發起請求,再將響應的內容填入到div容器中,這就存在頁面最開始白屏的問題。服務端渲染將這個過程放在了服務端,請求獲取響應後服務端將HTML填充好直接返回給瀏覽器,瀏覽器將整個完整的HTML直接渲染出來。顯而易見,服務端渲染少了在瀏覽器載入的過程,解決了頁面最開始白屏的問題,明顯的提高了首屏渲染的速度。
目前我們主要在電商導購頁、挖客分享頁中使用Vue的SSR,接下來我們主要講SSR的實現。
實現原理
實現流程
如上圖所示有兩個入口檔案Server entry和Client entry,分別經webpack打包成服務端用的Server Bundle和客戶端用的Client Bundle。
服務端:當Node Server收到來自客戶端的請求後, BundleRenderer 會讀取Server Bundle,並且執行它,而 Server Bundle實現了資料預取並將填充資料的Vue例項掛載在HTML模版上,接下來BundleRenderer將HTML渲染為字串,最後將完整的HTML返回給客戶端。
客戶端:瀏覽器收到HTML後,客戶端載入了Client Bundle,通過app.$mount('#app')
的方式將Vue例項掛載在服務端返回的靜態HTML上。如:
<div id="app" data-server-rendered="true">
複製程式碼
data-server-rendered
特殊屬性,讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,並且應該以啟用模式(Hydration)進行掛載。
目錄結構
.
├── build
│ ├── setup-dev-server.js # dev伺服器端設定 增加中介軟體支援
│ ├── webpack.base.config.js # 基本配置
│ ├── webpack.client.config.js # 客戶端配置
│ └── webpack.server.config.js # 服務端配置
├── cache_key.js # 根據引數判斷是否從快取中獲取
├── package.json # 專案依賴
├── process.debug.json # debug環境下的pm2配置檔案
├── process.json # 生產環境下pm2配置檔案
├── server.js # express 服務端入口檔案
├── src
│ ├── api
│ │ ├── create-api-client.js # 客戶端請求相關配置
│ │ ├── create-api-server.js # 伺服器請求相關配置
│ │ └── index.js # api請求
│ ├── app.js # 主入口檔案
│ ├── config # 相關配置
│ ├── entry-client.js # 客戶端入口檔案
│ ├── entry-server.js # 服務端入口檔案
│ ├── router # 路由
│ ├── store # store
│ ├── templates # 模版
│ └── views
複製程式碼
相關檔案
server.js
// 建立express應用
const app = express()
// 讀取模版檔案
const template = fs.readFileSync(resolve('./src/templates/index.template.html'), 'utf-8')
// 呼叫vue-server-renderer的createBundleRenderer方法建立渲染器,並設定HTML模板,之後將服務端預取的資料填充至模板中
function createRenderer (bundle, options) {
return createBundleRenderer(bundle, Object.assign(options, {
template,
basedir: resolve('./dist'),
runInNewContext: false
}))
}
let renderer
let readyPromise
if (!isDev) {
// 生產環境下,引入由webpack vue-ssr-webpack-plugin外掛生成的server bundle
const bundle = require('./dist/vue-ssr-server-bundle.json')
// 引入由 vue-server-renderer/client-plugin 生成的客戶端構建 manifest 物件。此物件包含了 webpack 整個構建過程的資訊,從而可以讓 bundle renderer 自動推導需要在 HTML 模板中注入的內容。
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
// vue-server-renderer建立bundle渲染器並繫結server bundle
renderer = createRenderer(bundle, {
clientManifest
})
} else {
// 開發環境下,使用dev-server來通過回撥把記憶體中的bundle檔案取回
// 通過dev server的webpack-dev-middleware和webpack-hot-middleware實現客戶端程式碼的熱更新
readyPromise = require('./build/setup-dev-server')(app, (bundle, options) => {
renderer = createRenderer(bundle, options)
})
}
// 設定靜態資源訪問
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isDev ? 0 : 1000 * 60 * 60 * 24 * 30
})
// 相關中介軟體 壓縮響應檔案 處理靜態資源等
app.use(...)
// 設定快取時間
const microCache = LRU({
maxAge: 1000 * 60 * 1
})
const isCacheable = req => useMicroCache
function render (req, res) {
const s = Date.now()
res.setHeader('Content-Type', 'text/html')
// 錯誤處理
const handleError = err => {}
// 根據path和query獲取cacheKey
let cacheKey = getCacheKey(req.path, req.query)
// 生產環境下預設開啟快取
const cacheable = isCacheable(req)
if (cacheable) {
const hit = microCache.get(cacheKey)
if (hit) {
// 從快取中獲取
console.log(`cache hit! key: ${cacheKey} query: ${JSON.stringify(req.query)}`)
return res.end(hit)
}
}
// 設定請求的url
const context = {
title: '',
url: req.url,
}
// 將Vue例項渲染為字串,傳入上下文物件。
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.end(html)
// 設定快取
if (cacheable) {
if (!isProd) {
console.log(`set cache, key: ${cacheKey}`)
}
microCache.set(cacheKey, html)
}
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
})
}
// 啟動一個服務並監聽8080埠
app.get('*', !isDev ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = process.env.PORT || 8080
const server = http.createServer(app)
server.listen(port, () => {
console.log(`server started at localhost:${port}`)
})
複製程式碼
整個流程大致如下:
- 建立渲染器,設定渲染模版、繫結Server Bundle
- 依次裝載一系列Express中介軟體,用於壓縮響應、處理靜態資源等
- 渲染器將裝載好的Vue的例項渲染為字串,響應到客戶端,並設定快取(以cacheKey為標識)
- 再次訪問時以cacheKey為標識,判斷是否從快取中獲取
entry.server.js
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
const { url, req } = context
const fullPath = router.resolve(url).route.fullPath
if (fullPath !== url) {
return reject({ url: fullPath })
}
// 切換路由到請求的url
router.push(url)
// 在路由完成初始導航時呼叫,可以解析所有的非同步進入鉤子和路由初始化相關聯的非同步元件,有效確保服務端渲染時服務端和客戶端輸出的一致。
router.onReady(() => {
// 獲取該路由相匹配的Vue components
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
reject({ code: 404 })
}
// 執行匹配元件中的asyncData
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute,
req
}))).then(() => {
// 在所有預取鉤子(preFetch hook) resolve 後,
// 我們的 store 現在已經填充入渲染應用程式所需的狀態。
// 當我們將狀態附加到上下文,
// 並且 `template` 選項用於 renderer 時,
// 狀態將自動序列化為 `window.__INITIAL_STATE__`,並注入 HTML。
context.state = store.state
if (router.currentRoute.meta) {
context.title = router.currentRoute.meta.title
}
// 返回一個初始化完整的Vue例項
resolve(app)
}).catch(reject)
}, reject)
})
}
複製程式碼
entry-client.js
import 'es6-promise/auto'
import { createApp } from './app'
const { app, router, store } = createApp()
// 由於服務端渲染時,context.state 作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中。在客戶端,在掛載到應用程式之前,state為window.__INITIAL_STATE__。
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
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)
})
const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
if (!asyncDataHooks.length) {
return next()
}
Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
.then(() => {
next()
})
.catch(next)
})
// 掛載在DOM上
app.$mount('#app')
})
複製程式碼
遇到的問題
1. 本地儲存
以往在使用SPA時,我們一般使用localStorage和sessionStorage進行部分資訊的本地儲存,有時候發起請求的時候需要帶上這些資訊。然而在使用SSR時,我們在asyncData這個鉤子中發起請求獲取資料,此時並不能獲取到window物件下的localStorage這個物件。 我們將資訊儲存在cookie中,在asyncData獲取資料時,通過req.headers獲取cookie。
2. 避開服務端與瀏覽器差異
這個問題其實和第一個問題有些類似,服務端和瀏覽器最大的差別在於有無window物件。我們可以通過判斷去避開:
// 解決移動端300ms延遲問題
if (typeof window !== "undefined") {
const Fastclick = require('fastclick')
Fastclick.attach(document.body)
}
複製程式碼
其實更好的解決方式是在entry-client.js中:
import FastClick from 'fastclick'
FastClick.attach(document.body)
複製程式碼
3. not matching
[vue warn]The client-side rendered virtual DOM tree is not matching server-rendered content
複製程式碼
這個問題是服務端與客戶端渲染的HTML不一致導致的。很大可能是出現{{ msg }}
這樣的寫法中的多餘空格導致的,我們要盡力避免在template中使用多餘的空格。