為了解決 vue 項目的 seo 問題,最近研究了下服務(wù)端渲染,所以就有了本文的記錄。
項目結(jié)構(gòu)
├─.babelrc // babel 配置文件
├─index.template.html // html 模板文件
├─server.js // 提供服務(wù)端渲染及 api 服務(wù)
├─src // 前端代碼
| ├─app.js // 主要用于創(chuàng)建 vue 實例
| ├─App.vue // 根組件
| ├─entry-client.js // 客戶端渲染入口文件
| ├─entry-server.js // 服務(wù)端渲染入口文件
| ├─stores // vuex 相關(guān)
| ├─routes // vue-router 相關(guān)
| ├─components // 組件
├─dist // 代碼編譯目標(biāo)路徑
├─build // webpack 配置文件
項目的主要目錄結(jié)構(gòu)如上所示,其中 package.json 請查看項目。關(guān)于為什么要使用狀態(tài)管理庫 Vuex,官網(wǎng)有明確的解釋。后文有例子幫助進(jìn)一步理解。
接下來我們暫時不管服務(wù)端渲染的事情,先搭建一個簡單的 vue 的開發(fā)環(huán)境。
搭建 vue 開發(fā)環(huán)境
利用 webpack 可以非??焖俚拇罱ㄒ粋€簡單的 vue 開發(fā)環(huán)境,可以直接乘電梯前往。
為了高效地進(jìn)行開發(fā),vue 開發(fā)環(huán)境應(yīng)該有代碼熱加載和請求轉(zhuǎn)發(fā)的功能。這些都可以使用 webpack-dev-server 來輕松實現(xiàn),只需配置 webpack 的 devServer 項:
module.exports = merge(baseWebpackConfig, {
devServer: {
historyApiFallback: true,
noInfo: true,
overlay: true,
proxy: config.proxy
},
devtool: '#eval-source-map',
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.template.html',
inject: true // 插入css和js
}),
new webpack.HotModuleReplacementPlugin(),
new FriendlyErrors()
]
})
然后啟動時添加 --hot 參數(shù)即可:
cross-env NODE_ENV=development webpack-dev-server --config build/webpack.dev.conf.js --open --hot
注意到 router 和 store 以及 vue 都采用了工廠函數(shù)來生成實例,這是為了方便代碼在后面的服務(wù)端渲染中進(jìn)行復(fù)用,因為 “Node.js 服務(wù)器是一個長期運行的進(jìn)程。必須為每個請求創(chuàng)建一個新的 Vue 實例” (官網(wǎng))。
同樣,前端請求使用的是 axios 庫,也是為了照顧服務(wù)端。
在項目根目錄下運行 npm run server 啟動后端 api 服務(wù),然后運行 npm run dev ,webpack 會自動在默認(rèn)瀏覽器中打開 http://localhost:8080 地址,即可看到效果。
服務(wù)端渲染
基于上面搭建好的項目基礎(chǔ)上來搭建服務(wù)端渲染就比較容易了,讓我們開始吧?;蛘咧苯涌醋詈蟮?a target="_blank" rel="nofollow">代碼。
要實現(xiàn)服務(wù)端渲染,只需增加如下 webpack 配置:
module.exports = merge(baseWebpackConfig, {
entry: './src/entry-server.js',
// 告知 `vue-loader` 輸送面向服務(wù)器代碼(server-oriented code)。
target: 'node',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2',
},
plugins: [
new VueSSRServerPlugin()
]
})
注意到 entry 的文件路徑跟之前的不太一樣,這里使用的是專門為服務(wù)端渲染準(zhǔn)備的入口文件:
import { createApp } from './app'
// 這里的 context 是服務(wù)端渲染模板時傳入的
export default context => {
// 因為有可能會是異步路由鉤子函數(shù)或組件,所以我們將返回一個 Promise,
// 以便服務(wù)器能夠等待所有的內(nèi)容在渲染前,
// 就已經(jīng)準(zhǔn)備就緒。
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
const { url } = context
const { fullPath } = router.resolve(url).route
if (fullPath !== url) {
return reject({ url: fullPath })
}
router.push(url)
// 等到 router 將可能的異步組件和鉤子函數(shù)解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,執(zhí)行 reject 函數(shù),并返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 執(zhí)行所有組件中的異步數(shù)據(jù)請求
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute
}))).then(() => {
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
其中的 asyncData 可能會讓人疑惑,稍后我們用一個例子來說明?,F(xiàn)在,然我們來編譯一下,運行 npm run build:server ,將會在 dist 目錄下得到 vue-ssr-server-bundle.json 文件??梢钥吹?,該文件包含了 webpack 打包生成的所有 chunk 并指定了入口。后面服務(wù)端會基于該文件來做渲染。
現(xiàn)在就讓我們移步服務(wù)端,新增一些代碼:
...
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require('./dist/vue-ssr-server-bundle.json')
const renderer = createBundleRenderer(bundle, {
template: fs.readFileSync('./index.template.html', 'utf-8')
})
...
// 服務(wù)端渲染
server.get('*', (req, res) => {
const context = { url: req.originalUrl }
renderer.renderToString(context, (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)
}
})
})
新增代碼不多,首先使用上面生成的文件創(chuàng)建了一個 renderer 對象,然后調(diào)用其 renderToString 方法并傳入包含請求路徑的對象作為參數(shù)來進(jìn)行渲染,最后將渲染好的數(shù)據(jù)即 html 返回。
運行 npm run server 啟動服務(wù)端,打開 http://localhost:8081 就可以看到效果了:

關(guān)于 asyncData
前面提到了 asyncData ,現(xiàn)在以該例子來梳理一下。首先,看看組件中的代碼:
...
<script>
export default {
asyncData ({ store, route }) {
// 觸發(fā) action 后,會返回 Promise
return store.dispatch('fetchItems')
},
data () {
return {
title: "",
content: ""
}
},
computed: {
// 從 store 的 state 對象中的獲取 item。
itemList () {
return this.$store.state.items
}
},
methods: {
submit () {
const {title, content} = this
this.$store.dispatch('addItem', {title, content})
}
}
}
</script>
這是一個很簡單的組件,包括一個列表,該列表的內(nèi)容通過請求從后端獲取,一個表單,用于提交新的記錄到后端保存。其中 asyncData 是我們約定的函數(shù)名,表示渲染組件需要預(yù)先執(zhí)行它獲取初始數(shù)據(jù),它返回一個 Promise,以便我們在后端渲染的時候可以知道什么時候該操作完成。這里,該函數(shù)觸發(fā)了 fetchItems 以更新 store 中的狀態(tài)。還記得我們的 entry-server.js 文件嗎,里面正是調(diào)用了組件的 asyncData 方法來進(jìn)行數(shù)據(jù)預(yù)取的。
在開發(fā)階段,我們同樣需要進(jìn)行數(shù)據(jù)預(yù)取,為了復(fù)用 asyncData 代碼,我們在組件的 beforeMount 中調(diào)用該方法,我們將這個處理邏輯通過 Vue.mixin 混入到所有的組件中:
Vue.mixin({
beforeMount() {
const { asyncData } = this.$options
if (asyncData) {
// 將獲取數(shù)據(jù)操作分配給 promise
// 以便在組件中,我們可以在數(shù)據(jù)準(zhǔn)備就緒后
// 通過運行 `this.dataPromise.then(...)` 來執(zhí)行其他任務(wù)
this.dataPromise = asyncData({
store: this.$store,
route: this.$route
})
}
}
})
還有一個問題就是我們生成的 html 中并沒有引入任何 js,用戶無法進(jìn)行任何交互,比如上面的列表頁,用戶無法提交新的內(nèi)容。當(dāng)然,如果這個頁面是只給爬蟲來“看”的話這樣就足夠了,但如果考慮到真實的用戶,我們還需要在 html 中引入前端渲染的 js 文件。
前端渲染
該部分的代碼可以直接查看這里。
前端渲染部分需要先增加一個 webpack 的配置文件用于生成所需的 js, css 等靜態(tài)文件:
module.exports = merge(baseWebpackConfig, {
plugins: [
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false,
drop_console: true
}
}),
// 重要信息:這將 webpack 運行時分離到一個引導(dǎo) chunk 中,
// 以便可以在之后正確注入異步 chunk。
// 這也為你的 應(yīng)用程序/vendor 代碼提供了更好的緩存。
new webpack.optimize.CommonsChunkPlugin({
name: "manifest",
minChunks: Infinity
}),
// 此插件在輸出目錄中
// 生成 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})
同時,前端渲染還需要有自己的入口文件 entry-client,該文件在講 asyncData 的時候有所提及:
import Vue from 'vue'
import {
createApp
} from './app.js'
// 客戶端特定引導(dǎo)邏輯……
const {
app,
router,
store
} = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
Vue.mixin({
beforeMount() {
const { asyncData } = this.$options
if (asyncData) {
// 將獲取數(shù)據(jù)操作分配給 promise
// 以便在組件中,我們可以在數(shù)據(jù)準(zhǔn)備就緒后
// 通過運行 `this.dataPromise.then(...)` 來執(zhí)行其他任務(wù)
this.dataPromise = asyncData({
store: this.$store,
route: this.$route
})
}
}
})
// 這里假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
app.$mount('#app')
})
現(xiàn)在我們 npm run build:client 編譯一下,dist 目錄中可以得到若干文件:
0.js
1.js
2.js
app.js
manifest.js
vue-ssr-client-manifest.json
其中,js 文件都是需要引入的文件,json 文件像是一個說明文檔,這里暫不討論其原理,感興趣的可以查看這里。
最后,server.js 中,稍微做一點點修改:
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(bundle, {
template: fs.readFileSync('./index.template.html', 'utf-8'),
clientManifest
})
然后 npm run server 啟動服務(wù),再打開 http://localhost:8081,可以看到渲染后的 html 文件中已經(jīng)引入了 js 資源了。

列表頁中也可以提交新記錄了:

總結(jié)
本文先從搭建一個簡單的 vue 開發(fā)環(huán)境開始,然后基于此實現(xiàn)了服務(wù)端渲染,并引入了客戶端渲染所需的資源。通過這個過程跑通了 vue 服務(wù)端渲染的大致流程,但很多地方還需更進(jìn)一步深入:
樣式的處理
本文并沒有對樣式進(jìn)行處理,需進(jìn)一步研究編譯后文件的解釋
文章中編譯生成的 json 等文件到底是怎么用的呢?針對爬蟲和真實用戶的不同策略
服務(wù)端渲染其實主要是用來解決 seo 的問題,所以可以在服務(wù)端通過請求頭判斷來源并做不同處理,若是爬蟲則進(jìn)行服務(wù)端渲染(不需要引入客戶端渲染所需的資源),若是普通用戶則還是用原始的客戶端渲染方式。