為了解決 vue 專案的 seo 問題,最近研究了下服務端渲染,所以就有了本文的記錄。
專案結構
├─.babelrc // babel 配置檔案
├─index.template.html // html 模板檔案
├─server.js // 提供服務端渲染及 api 服務
├─src // 前端程式碼
| ├─app.js // 主要用於建立 vue 例項
| ├─App.vue // 根元件
| ├─entry-client.js // 客戶端渲染入口檔案
| ├─entry-server.js // 服務端渲染入口檔案
| ├─stores // vuex 相關
| ├─routes // vue-router 相關
| ├─components // 元件
├─dist // 程式碼編譯目標路徑
├─build // webpack 配置檔案
複製程式碼
專案的主要目錄結構如上所示,其中 package.json
請檢視專案。關於為什麼要使用狀態管理庫 Vuex,官網有明確的解釋。後文有例子幫助進一步理解。
接下來我們暫時不管服務端渲染的事情,先搭建一個簡單的 vue
的開發環境。
搭建 vue 開發環境
利用 webpack
可以非常快速的搭建一個簡單的 vue
開發環境,可以直接乘電梯前往。
為了高效地進行開發,vue
開發環境應該有程式碼熱載入和請求轉發的功能。這些都可以使用 webpack-dev-server
來輕鬆實現,只需配置 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
引數即可:
cross-env NODE_ENV=development webpack-dev-server --config build/webpack.dev.conf.js --open --hot
複製程式碼
注意到 router
和 store
以及 vue
都採用了工廠函式來生成例項,這是為了方便程式碼在後面的服務端渲染中進行復用,因為 “Node.js 伺服器是一個長期執行的程式。必須為每個請求建立一個新的 Vue 例項” (官網)。
同樣,前端請求使用的是 axios
庫,也是為了照顧服務端。
在專案根目錄下執行 npm run server
啟動後端 api 服務,然後執行 npm run dev
,webpack
會自動在預設瀏覽器中開啟 http://localhost:8080
地址,即可看到效果。
服務端渲染
基於上面搭建好的專案基礎上來搭建服務端渲染就比較容易了,讓我們開始吧。或者直接看最後的程式碼。
要實現服務端渲染,只需增加如下 webpack
配置:
module.exports = merge(baseWebpackConfig, {
entry: './src/entry-server.js',
// 告知 `vue-loader` 輸送面向伺服器程式碼(server-oriented code)。
target: 'node',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2',
},
plugins: [
new VueSSRServerPlugin()
]
})
複製程式碼
注意到 entry
的檔案路徑跟之前的不太一樣,這裡使用的是專門為服務端渲染準備的入口檔案:
import { createApp } from './app'
// 這裡的 context 是服務端渲染模板時傳入的
export default context => {
// 因為有可能會是非同步路由鉤子函式或元件,所以我們將返回一個 Promise,
// 以便伺服器能夠等待所有的內容在渲染前,
// 就已經準備就緒。
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 將可能的非同步元件和鉤子函式解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// 匹配不到的路由,執行 reject 函式,並返回 404
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 執行所有元件中的非同步資料請求
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute
}))).then(() => {
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
複製程式碼
其中的 asyncData
可能會讓人疑惑,稍後我們用一個例子來說明。現在,然我們來編譯一下,執行 npm run build:server
,將會在 dist
目錄下得到 vue-ssr-server-bundle.json 檔案。可以看到,該檔案包含了 webpack
打包生成的所有 chunk
並指定了入口。後面服務端會基於該檔案來做渲染。
現在就讓我們移步服務端,新增一些程式碼:
...
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')
})
...
// 服務端渲染
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)
}
})
})
複製程式碼
新增程式碼不多,首先使用上面生成的檔案建立了一個 renderer
物件,然後呼叫其 renderToString
方法並傳入包含請求路徑的物件作為引數來進行渲染,最後將渲染好的資料即 html
返回。
執行 npm run server
啟動服務端,開啟 http://localhost:8081
就可以看到效果了:
關於 asyncData
前面提到了 asyncData
,現在以該例子來梳理一下。首先,看看元件中的程式碼:
...
<script>
export default {
asyncData ({ store, route }) {
// 觸發 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>
複製程式碼
這是一個很簡單的元件,包括一個列表,該列表的內容通過請求從後端獲取,一個表單,用於提交新的記錄到後端儲存。其中 asyncData
是我們約定的函式名,表示渲染元件需要預先執行它獲取初始資料,它返回一個 Promise
,以便我們在後端渲染的時候可以知道什麼時候該操作完成。這裡,該函式觸發了 fetchItems
以更新 store
中的狀態。還記得我們的 entry-server.js 檔案嗎,裡面正是呼叫了元件的 asyncData
方法來進行資料預取的。
在開發階段,我們同樣需要進行資料預取,為了複用 asyncData
程式碼,我們在元件的 beforeMount
中呼叫該方法,我們將這個處理邏輯通過 Vue.mixin
混入到所有的元件中:
Vue.mixin({
beforeMount() {
const { asyncData } = this.$options
if (asyncData) {
// 將獲取資料操作分配給 promise
// 以便在元件中,我們可以在資料準備就緒後
// 通過執行 `this.dataPromise.then(...)` 來執行其他任務
this.dataPromise = asyncData({
store: this.$store,
route: this.$route
})
}
}
})
複製程式碼
還有一個問題就是我們生成的 html 中並沒有引入任何 js,使用者無法進行任何互動,比如上面的列表頁,使用者無法提交新的內容。當然,如果這個頁面是隻給爬蟲來“看”的話這樣就足夠了,但如果考慮到真實的使用者,我們還需要在 html 中引入前端渲染的 js 檔案。
前端渲染
該部分的程式碼可以直接檢視這裡。
前端渲染部分需要先增加一個 webpack
的配置檔案用於生成所需的 js, css 等靜態檔案:
module.exports = merge(baseWebpackConfig, {
plugins: [
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false,
drop_console: true
}
}),
// 重要資訊:這將 webpack 執行時分離到一個引導 chunk 中,
// 以便可以在之後正確注入非同步 chunk。
// 這也為你的 應用程式/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'
// 客戶端特定引導邏輯……
const {
app,
router,
store
} = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
Vue.mixin({
beforeMount() {
const { asyncData } = this.$options
if (asyncData) {
// 將獲取資料操作分配給 promise
// 以便在元件中,我們可以在資料準備就緒後
// 通過執行 `this.dataPromise.then(...)` 來執行其他任務
this.dataPromise = asyncData({
store: this.$store,
route: this.$route
})
}
}
})
// 這裡假定 App.vue 模板中根元素具有 `id="app"`
router.onReady(() => {
app.$mount('#app')
})
複製程式碼
現在我們 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
啟動服務,再開啟 http://localhost:8081
,可以看到渲染後的 html 檔案中已經引入了 js 資源了。
列表頁中也可以提交新記錄了:
總結
本文先從搭建一個簡單的 vue 開發環境開始,然後基於此實現了服務端渲染,並引入了客戶端渲染所需的資源。通過這個過程跑通了 vue 服務端渲染的大致流程,但很多地方還需更進一步深入:
-
樣式的處理
本文並沒有對樣式進行處理,需進一步研究
-
編譯後檔案的解釋
文章中編譯生成的 json 等檔案到底是怎麼用的呢?
-
針對爬蟲和真實使用者的不同策略
服務端渲染其實主要是用來解決 seo 的問題,所以可以在服務端通過請求頭判斷來源並做不同處理,若是爬蟲則進行服務端渲染(不需要引入客戶端渲染所需的資源),若是普通使用者則還是用原始的客戶端渲染方式。