SSR(Server-Side Rendering),在SPA(Single-Page Application)出現以前,網頁就是在服務端渲染的。服務器接收到客戶端請求後,將數據和模板拼接成完整的頁面響應到客戶端,客戶端將響應結果渲染出來。若是用戶須要瀏覽新的頁面,則須要重複這個過程。隨着Angular、React和Vue的興起,SPA開始流行,單頁面應用能夠在不重載整個頁面的狀況下,經過ajax和服務器進行交互,高效更新部分頁面,這無疑帶來了良好的用戶體驗。然而,對於須要SEO、追求首屏速度的頁面,使用SPA是糟糕的。若是咱們想使用Vue,又須要考慮到SEO、首屏渲染速度,那該怎麼辦?好在Vue是支持服務端渲染的,接下來咱們主要說的是Vue的服務端渲染。javascript
咱們主要在管理後臺系統和內嵌H5電商頁中使用Vue,對於管理後臺系統,不須要考慮SEO和首屏渲染時間,因此是否用SPA的方式其實問題不大。而對於電商頁,雖然不須要SEO,可是首屏渲染變得十分重要。通常的SPA頁面打開時,HTML大致的結構以下: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
目前咱們主要在電商導購頁、挖客分享頁中使用Vue的SSR,接下來咱們主要講SSR的實現。java
如上圖所示有兩個入口文件Server entry和Client entry,分別經webpack打包成服務端用的Server Bundle和客戶端用的Client Bundle。webpack
服務端:當Node Server收到來自客戶端的請求後, BundleRenderer 會讀取Server Bundle,而且執行它,而 Server Bundle實現了數據預取並將填充數據的Vue實例掛載在HTML模版上,接下來BundleRenderer將HTML渲染爲字符串,最後將完整的HTML返回給客戶端。es6
客戶端:瀏覽器收到HTML後,客戶端加載了Client Bundle,經過app.$mount('#app')
的方式將Vue實例掛載在服務端返回的靜態HTML上。如:web
<div id="app" data-server-rendered="true">
複製代碼
data-server-rendered
特殊屬性,讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,而且應該以激活模式(Hydration)進行掛載。ajax
.
├── 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
複製代碼
// 建立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}`)
})
複製代碼
整個流程大體以下:express
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)
})
}
複製代碼
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')
})
複製代碼
以往在使用SPA時,咱們通常使用localStorage和sessionStorage進行部分信息的本地存儲,有時候發起請求的時候須要帶上這些信息。然而在使用SSR時,咱們在asyncData這個鉤子中發起請求獲取數據,此時並不能獲取到window對象下的localStorage這個對象。 咱們將信息存儲在cookie中,在asyncData獲取數據時,經過req.headers獲取cookie。json
這個問題其實和第一個問題有些相似,服務端和瀏覽器最大的差異在於有無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)
複製代碼
[vue warn]The client-side rendered virtual DOM tree is not matching server-rendered content
複製代碼
這個問題是服務端與客戶端渲染的HTML不一致致使的。很大多是出現{{ msg }}
這樣的寫法中的多餘空格致使的,咱們要盡力避免在template中使用多餘的空格。