筆者最近在和小夥伴對vue專案進行ssr的升級,本文筆者將根據一個簡單拿vue cli構建的客戶端渲染的demo一步一步的教大家打造自己的ssr,拙見勿噴哈。
what ? why ?
What ?
在學習一項新技術的時候我們首先要了解一下他是什麼。這裡引用官網的一句話:
Vue.js 是構建客戶端應用程式的框架。預設情況下,可以在瀏覽器中輸出 Vue 元件,進行生成 DOM 和操作DOM。然而,也可以將同一個元件渲染為伺服器端的 HTML 字串,將它們直接傳送到瀏覽器,最後將靜態標記"混合"為客戶端上完全互動的應用程式。
Why ?
知道是什麼後我們要知道這項技術對我們現有的專案有什麼好處,簡單總結一下:
- 利於SEO,瀏覽器爬蟲不會等待我們的ajax回撥完成之後再去抓取我們的頁面資料;
- 利於首屏渲染,vue-ssr會把拿到的資料渲染成html,不用等待全部的js資源都完成下載才顯示我們的頁面;
do ? how to do ?
這裡我們用vue-cli去簡單的做一個vue客戶端渲染的demo,具體過程就不做贅述了。
demo地址: https://github.com/LNoe-lzy/vue-ssr-demo/tree/master
這裡我們根據之前寫好的客戶端渲染的demo來一步一步的改造成服務端渲染。先甩下demo連結:
demo地址: https://github.com/LNoe-lzy/vue-ssr-demo/tree/vue-ssr-server
First step:理解下原理
先附一張鎮文之圖,官網的構建流程:
這些都是個啥?
- app.js用來構建我們的vue例項,這個例項會跑在客戶端和服務端;
- server entry是我們的服務端entry,用來匯出一個函式在每次請求中呼叫,也做元件匹配和初始化渲染資料的獲取。webpack會將其打包成server bundle;
- client entry是我們客戶端的entry,用來掛載我們的vue例項到指定的dom元素上。webpack會將其打包成client bundle;
這些都做了啥?
- 首先我們的entry-server會獲取到當前router匹配到的元件,呼叫元件上asyncData方法,將資料存到服務端的vuex中,然後服務端vuex中的這些資料傳給我們的context。
- Node.js伺服器通過renderToString將需要首屏渲染的html字串send道我們的客戶端上,這其中混入了window.INITIAL_STATE 用來儲存我們服務端vuex的資料。
- 然後entry-client,此時服務端渲染時候拿到的資料寫入客戶端的vuex中。
- 最後就是客戶端和服務端的元件做diff了,更新狀態更新的元件。
Secound step:main.js的改造
為了避免單例的影響,我們需要在每個請求都建立一個新的vue的例項,從而避免請求狀態的汙染,我們來封裝一個createApp的工廠函式:
import Vue from 'vue'
import App from './App'
export function createApp () {
const app = new Vue({
render: h => h(App)
})
return { app }
}
複製程式碼
Third step:元件的改造
跑在服務端的Vue中所有的生命週期鉤子函式中,只有 beforeCreate 和 created 會在伺服器端渲染過程中被呼叫,而其他的鉤子在客戶端才會被呼叫,畢竟我們的服務端是無法執行dom操作的,所以我們要在路由匹配的元件上定義一個靜態函式,這個函式要做的也很簡單,就是去dispatch我們的action從而非同步獲取資料:
import { mapActions } from 'vuex'
export default {
asyncData ({ store }) {
return store.dispatch('getNav')
},
methods: {
...mapActions([
'getList'
])
}
// ...
}
複製程式碼
Fourth step:router和store的改造
同樣為了避免單例的影響,我們也需要用工廠函式封裝我們的router和store
// router
export function createRouter () {
return new Router({
mode: 'history',
routes: []
})
}
// store
export function createStore () {
return new Vuex.Store({
state: {},
actions,
mutations
})
}
複製程式碼
Fifth step:兩個entry
根據構建流程圖我們還需要webpack去構建兩個bundle,服務端根據Server Bundle去做ssr,瀏覽器根據Client Bundle去混合靜態標記。
為此我們在src目錄下新建兩個檔案,entry-server.js 和 entry-client.js。前者在每次渲染中需要重複呼叫,執行服務端的路有匹配和資料預取邏輯。後者負責掛載DOM節點,以及前後端vuex資料狀態的同步。
// entry-server.js
import { createApp } from './main'
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(new Error(`error: ${fullPath}`))
}
router.push(url)
// 需要等到的非同步元件和鉤子函式解析完
router.onReady(() => {
// 獲取匹配到的元件
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
store,
route: router.currentRoute
}))).then(() => {
// 將預取的資料從store中取出放到context中
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
複製程式碼
這裡我們需要注意兩點,一個是我們的資料預取是呼叫元件的asyncData方法,所以需要Promise.all來保證拿到全部的預渲染資料;另一點是context.state = store.state,這時候服務端拿到的預渲染資料會封在**window.INITIAL_STATE**中通過node伺服器send到客戶端。
import Vue from 'vue'
import { createApp } from './main'
const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// 也是處理非同步元件
router.onReady(() => {
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)
})
console.log('router ready')
app.$mount('#app')
})
複製程式碼
看到window.INITIAL_STATE我們就可以知道了客戶端拿到了預取的資料,然後去存到客戶端的vuex中,這也就是大家經常談論的通過vuex實現前後端的狀態共享。
至於vuex是不是必須的,當然不是(尤大issuse有說),題外話,筆者也實現了沒有vuex的版本哦。
Sixth step:webpack的改造
webpack的配置上面其實和純客戶端應用類似,為了區分客戶端和服務端兩個環境我們將配置分為base、client和server三部分,base就是我們的通用基礎配置,而client和server分別用來打包我們的客戶端和服務端程式碼。
首先是webpack.server.conf.js,用於生成server bundle來傳遞給createBundleRenderer函式在node伺服器上呼叫,入口是我們的entry-server:
const webpack = require('webpack')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.conf.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
// 去除打包css的配置
baseConfig.module.rules[1].options = ''
module.exports = merge(baseConfig, {
entry: './src/entry-server.js',
// 以 Node 適用方式匯入
target: 'node',
// 對 bundle renderer 提供 source map 支援
devtool: '#source-map',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
externals: nodeExternals({
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
// 這是將伺服器的整個輸出
// 構建為單個 JSON 檔案的外掛。
// 預設檔名為 `vue-ssr-server-bundle.json`
new VueSSRServerPlugin()
]
})
複製程式碼
其次是webpack.client.conf.js,這裡我們可以根據官方的配置生成clientManifest,自動推斷和注入資源預載入,以及 css 連結 / script 標籤到所渲染的 HTML。入口是我們的client-server:
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.conf')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(base, {
entry: {
app: './src/entry-client.js'
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
return (
/node_modules/.test(module.context) &&
!/\.css$/.test(module.request)
)
}
}),
// 這將 webpack 執行時分離到一個引導 chunk 中,
// 以便可以在之後正確注入非同步 chunk。
// 這也為你的 應用程式/vendor 程式碼提供了更好的快取。
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin()
]
})
複製程式碼
Seventh step:編寫服務端程式碼
服務端框架我們採用Express(當然Koa2也是可以的):
const express = require('express')
const fs = require('fs')
const path = require('path')
const {
createBundleRenderer
} = require('vue-server-renderer')
const app = express()
const resolve = file => path.resolve(__dirname, file)
// 生成服務端渲染函式
const renderer = createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'), {
runInNewContext: false,
template: fs.readFileSync(resolve('./index.html'), 'utf-8'),
clientManifest: require('./dist/vue-ssr-client-manifest.json'),
basedir: resolve('./dist')
})
// 引入靜態資源
app.use(express.static(path.join(__dirname, 'dist')))
// 分發路由
app.get('*', (req, res) => {
res.setHeader('Content-Type', 'text/html')
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if (err.code === 404) {
res.status(404).send('404 | Page Not Found')
} else {
// Render Error Page or Redirect
res.status(500).send('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err.stack)
}
}
const context = {
title: 'Vue SSR demo', // default title
url: req.url
}
renderer.renderToString(context, (err, html) => {
console.log('render')
if (err) {
return handleError(err)
}
res.send(html)
})
})
app.on('error', err => console.log(err))
app.listen(3000, () => {
console.log(`vue ssr started at localhost:3000`)
})
複製程式碼
通過觀察localhost我們可以很清楚的發現,通過服務端send過來的html字串僅包括我們根據資料預取渲染出來的dom結構以及服務端混入的window.INITIAL_STATE
通過Performance我們也可以看出在採用了ssr的應用中,我們的首屏渲染並不依賴於客服端的js檔案了,這就大大加快了首屏的渲染速度,畢竟傳統的SPA應用時需要拿到客戶端js檔案後才可以進行虛擬dom的構建以及資料的獲取工作才渲染頁面的。
不只是題外話
- vue-router不是必須的,不用router其實做個vue的preRender就可以了,完全沒必要做ssr;
- vuex不是必須的,vuex是實現我們客戶端和服務端的狀態共享的關鍵,我們可以不使用vuex,但是我們得去實現一套資料預取的邏輯;
不使用vuex其實很頭疼,但又有了點靈感,平時我們在開發專案的時候是如何處理元件間通訊的,一個是vuex,另一個是EventBus,EventBus就是個Vue的例項啊,資料存這裡不也行麼?
在此筆者的思路是:建立一個Vue的例項充當倉庫,那麼我們可以用這個例項的data來儲存我們的預取資料,而用methods中的方法去做資料的非同步獲取,這樣我們只需要在需要預取資料的元件中去呼叫這個方法就可以了。demo很簡單,戳這裡
還有一個思路是在筆者學習的時候看別人部落格學到的:只用了vuex的store和一些支援服務端渲染的api,沒有走action、mutation那套,而是將資料手動寫入state,為了表示對別人部落格的尊重,細節就請轉到作者的部落格吧,戳這裡
寫在最後
本文通過一個簡單的客戶端渲染demo來一步一步的交大家如何搭建屬於自己的ssr程式,文筆拙略還請大家諒解了。
不過學習雖好,但是細節到使用上,大家還是斟酌是否適合在自己的專案中。
多謝支援!