建議先閱讀官方指南——Vue.js 伺服器端渲染指南,再回到本文開始閱讀。
本文將分成以下兩部分:
- 簡述 Vue SSR 過程
- 從零開始搭建 SSR 專案
好了,下面開始正文。
簡述 Vue SSR 過程
客戶端渲染過程
- 訪問客戶端渲染的網站。
- 伺服器返回一個包含了引入資源語句和
<div id="app"></div>
的 HTML 檔案。 - 客戶端通過 HTTP 向伺服器請求資源,當必要的資源都載入完畢後,執行
new Vue()
開始例項化並渲染頁面。
服務端渲染過程
- 訪問服務端渲染的網站。
- 伺服器會檢視當前路由元件需要哪些資原始檔,然後將這些檔案的內容填充到 HTML 檔案。如果有
asyncData()
函式,就會執行它進行資料預取並填充到 HTML 檔案裡,最後返回這個 HTML 頁面。 - 當客戶端接收到這個 HTML 頁面時,可以馬上就開始渲染頁面。與此同時,頁面也會載入資源,當必要的資源都載入完畢後,開始執行
new Vue()
開始例項化並接管頁面。
從上述兩個過程中,可以看出,區別就在於第二步。客戶端渲染的網站會直接返回 HTML 檔案,而服務端渲染的網站則會渲染完頁面再返回這個 HTML 檔案。
這樣做的好處是什麼?是更快的內容到達時間 (time-to-content)。
假設你的網站需要載入完 abcd 四個檔案才能渲染完畢。並且每個檔案大小為 1 M。
這樣一算:客戶端渲染的網站需要載入 4 個檔案和 HTML 檔案才能完成首頁渲染,總計大小為 4M(忽略 HTML 檔案大小)。而服務端渲染的網站只需要載入一個渲染完畢的 HTML 檔案就能完成首頁渲染,總計大小為已經渲染完畢的 HTML 檔案(這種檔案不會太大,一般為幾百K,我的個人部落格網站(SSR)載入的 HTML 檔案為 400K)。這就是服務端渲染更快的原因。
客戶端接管頁面
對於服務端返回來的 HTML 檔案,客戶端必須進行接管,對其進行 new Vue()
例項化,使用者才能正常使用頁面。
如果不對其進行啟用的話,裡面的內容只是一串字串而已,例如下面的程式碼,點選是無效的:
<button @click="sayHi">如果不進行啟用,點我是不會觸發事件的</button>
那客戶端如何接管頁面呢?下面引用一篇文章中的內容:
客戶端 new Vue() 時,客戶端會和服務端生成的DOM進行Hydration對比(判斷這個DOM和自己即將生成的DOM是否相同(vuex store 資料同步才能保持一致)如果相同就呼叫app.$mount('#app')將客戶端的vue例項掛載到這個DOM上,即去“啟用”這些服務端渲染的HTML之後,其變成了由Vue動態管理的DOM,以便響應後續資料的變化,即之後所有的互動和vue-router不同頁面之間的跳轉將全部在瀏覽器端執行。
如果客戶端構建的虛擬 DOM 樹與伺服器渲染返回的HTML結構不一致,這時候,客戶端會請求一次伺服器再渲染整個應用程式,這使得ssr失效了,達不到服務端渲染的目的了
小結
不管是客戶端渲染還是服務端渲染,都需要等待客戶端執行 new Vue()
之後,使用者才能進行互動操作。但服務端渲染的網站能讓使用者更快的看見頁面。
從零開始搭建 SSR 專案
配置 weback
webpack 配置檔案共有 3 個:
webpack.base.config.js
,基礎配置檔案,客戶端與服務端都需要它。webpack.client.config.js
,客戶端配置檔案,用於生成客戶端所需的資源。webpack.server.config.js
,服務端配置檔案,用於生成服務端所需的資源。
webpack.base.config.js
基礎配置檔案
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const isProd = process.env.NODE_ENV === 'production'
function resolve(dir) {
return path.join(__dirname, '..', dir)
}
module.exports = {
context: path.resolve(__dirname, '../'),
devtool: isProd ? 'source-map' : '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
// chunkhash 同屬一個 chunk 中的檔案修改了,檔名會發生變化
// contenthash 只有檔案自己的內容變化了,檔名才會變化
filename: '[name].[contenthash].js',
// 此選項給打包後的非入口js檔案命名,與 SplitChunksPlugin 配合使用
chunkFilename: '[name].[contenthash].js',
},
resolve: {
extensions: ['.js', '.vue', '.json', '.css'],
alias: {
public: resolve('public'),
'@': resolve('src')
}
},
module: {
// https://juejin.im/post/6844903689103081485
// 使用 `mini-css-extract-plugin` 外掛打包的的 `server bundle` 會使用到 document。
// 由於 node 環境中不存在 document 物件,所以報錯。
// 解決方案:樣式相關的 loader 不要放在 `webpack.base.config.js` 檔案
// 將其分拆到 `webpack.client.config.js` 和 `webpack.client.server.js` 檔案
// 其中 `mini-css-extract-plugin` 外掛要放在 `webpack.client.config.js` 檔案配置。
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|svg|jpg|gif|ico)$/,
use: ['file-loader']
},
{
test: /\.(woff|eot|ttf)\??.*$/,
loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
},
]
},
plugins: [new VueLoaderPlugin()],
}
基礎配置檔案比較簡單,output
屬性的意思是打包時根據檔案內容生成檔名稱。module
屬性配置不同檔案的解析 loader。
webpack.client.config.js
客戶端配置檔案
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const CompressionPlugin = require('compression-webpack-plugin')
const WebpackBar = require('webpackbar')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const isProd = process.env.NODE_ENV === 'production'
const plugins = [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'development'
),
'process.env.VUE_ENV': '"client"'
}),
new VueSSRClientPlugin(),
new MiniCssExtractPlugin({
filename: 'style.css'
})
]
if (isProd) {
plugins.push(
// 開啟 gzip 壓縮 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
new CompressionPlugin(),
// 該外掛會根據模組的相對路徑生成一個四位數的hash作為模組id, 用於生產環境。
new webpack.HashedModuleIdsPlugin(),
new WebpackBar(),
)
}
const config = {
entry: {
app: './src/entry-client.js'
},
plugins,
optimization: {
runtimeChunk: {
name: 'manifest'
},
splitChunks: {
cacheGroups: {
vendor: {
name: 'chunk-vendors',
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial',
},
common: {
name: 'chunk-common',
minChunks: 2,
priority: -20,
chunks: 'initial',
reuseExistingChunk: true
}
},
}
},
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
// 解決 export 'default' (imported as 'mod') was not found
// 啟用 CommonJS 語法
esModule: false,
},
},
'css-loader'
]
}
]
},
}
if (isProd) {
// 壓縮 css
config.optimization.minimizer = [
new CssMinimizerPlugin(),
]
}
module.exports = merge(base, config)
客戶端配置檔案中的 config.optimization
屬性是打包時分割程式碼用的。它的作用是將第三方庫都打包在一起。
其他外掛作用:
MiniCssExtractPlugin
外掛, 將 css 提取出來單獨打包。CssMinimizerPlugin
外掛,壓縮 css。CompressionPlugin
外掛,將資源壓縮成 gzip 格式(大大提升傳輸效率)。另外還需要在 node 伺服器上引入compression
外掛配合使用。WebpackBar
外掛,打包時顯示進度條。
webpack.server.config.js
服務端配置檔案
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals') // Webpack allows you to define externals - modules that should not be bundled.
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const WebpackBar = require('webpackbar')
const plugins = [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
if (process.env.NODE_ENV == 'production') {
plugins.push(
new WebpackBar()
)
}
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
externals: nodeExternals({
allowlist: /\.css$/ // 防止將某些 import 的包(package)打包到 bundle 中,而是在執行時(runtime)再去從外部獲取這些擴充套件依賴
}),
plugins,
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
}
]
},
})
服務端打包和客戶端不同,它將所有檔案一起打包成一個檔案 server-bundle.js
。同時解析 css 需要使用 vue-style-loader
,這一點在官方指南中有說明:
配置伺服器
生產環境
pro-server.js
生產環境伺服器配置檔案
const fs = require('fs')
const path = require('path')
const express = require('express')
const setApi = require('./api')
const LRU = require('lru-cache') // 快取
const { createBundleRenderer } = require('vue-server-renderer')
const favicon = require('serve-favicon')
const resolve = file => path.resolve(__dirname, file)
const app = express()
// 開啟 gzip 壓縮 https://github.com/woai3c/node-blog/blob/master/doc/optimize.md
const compression = require('compression')
app.use(compression())
// 設定 favicon
app.use(favicon(resolve('../public/favicon.ico')))
// 新版本 需要加 new,舊版本不用
const microCache = new LRU({
max: 100,
maxAge: 60 * 60 * 24 * 1000 // 重要提示:快取資源將在 1 天后過期。
})
const serve = (path) => {
return express.static(resolve(path), {
maxAge: 1000 * 60 * 60 * 24 * 30
})
}
app.use('/dist', serve('../dist', true))
function createRenderer(bundle, options) {
return createBundleRenderer(
bundle,
Object.assign(options, {
basedir: resolve('../dist'),
runInNewContext: false
})
)
}
function render(req, res) {
const hit = microCache.get(req.url)
if (hit) {
console.log('Response from cache')
return res.end(hit)
}
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 {
res.status(500).send('500 | Internal Server Error~')
console.log(err)
}
}
const context = {
title: 'SSR 測試', // default title
url: req.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
microCache.set(req.url, html)
res.send(html)
})
}
const templatePath = resolve('../public/index.template.html')
const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json') // 將js檔案注入到頁面中
const renderer = createRenderer(bundle, {
template,
clientManifest
})
const port = 8080
app.listen(port, () => {
console.log(`server started at localhost:${ port }`)
})
setApi(app)
app.get('*', render)
從程式碼中可以看到,當首次載入頁面時,需要呼叫 createBundleRenderer()
生成一個 renderer,它的引數是打包生成的 vue-ssr-server-bundle.json
和 vue-ssr-client-manifest.json
檔案。當返回 HTML 檔案後,頁面將會被客戶端接管。
在檔案的最後有一行程式碼 app.get('*', render)
,它表示所有匹配不到的請求都交給它處理。所以如果你寫了 ajax 請求處理函式必須放在前面,就像下面這樣:
app.get('/fetchData', (req, res) => { ... })
app.post('/changeData', (req, res) => { ... })
app.get('*', render)
否則你的頁面會打不開。
開發環境
開發環境的伺服器配置和生產環境沒什麼不同,區別在於開發環境下的伺服器有熱更新。
一般用 webpack 進行開發時,簡單的配置一下 dev server 引數就可以使用熱更新了,但是 SSR 專案需要自己配置。
由於 SSR 開發環境伺服器的配置檔案 setup-dev-server.js
程式碼太多,我對其進行簡化後,大致程式碼如下:
// dev-server.js
const express = require('express')
const webpack = require('webpack')
const webpackConfig = require('../build/webpack.dev') // 獲取 webpack 配置檔案
const compiler = webpack(webpackConfig)
const app = express()
app.use(require('webpack-hot-middleware')(compiler))
app.use(require('webpack-dev-middleware')(compiler, {
noInfo: true,
stats: {
colors: true
}
}))
同時需要在 webpack 的入口檔案加上這一行程式碼 webpack-hot-middleware/client?reload=true
。
// webpack.dev.js
const merge = require('webpack-merge')
const webpackBaseConfig = require('./webpack.base.config.js') // 這個配置和熱更新無關,可忽略
module.exports = merge(webpackBaseConfig, {
mode: 'development',
entry: {
app: ['webpack-hot-middleware/client?reload=true' , './client/main.js'] // 開啟熱模組更新
},
plugins: [new webpack.HotModuleReplacementPlugin()]
})
然後使用 node dev-server.js
來開啟前端程式碼熱更新。
熱更新主要使用了兩個外掛:webpack-dev-middleware
和 webpack-hot-middleware
。顧名思義,看名稱就知道它們的作用,
webpack-dev-middleware
的作用是生成一個與 webpack 的 compiler 繫結的中介軟體,然後在 express 啟動的 app 中呼叫這個中介軟體。
這個中介軟體的作用呢,簡單總結為以下三點:通過watch mode,監聽資源的變更,然後自動打包; 快速編譯,走記憶體;返回中介軟體,支援express 的 use 格式。
webpack-hot-middleware
外掛的作用就是熱更新,它需要配合 HotModuleReplacementPlugin
和 webpack-dev-middleware
一起使用。
打包檔案 vue-ssr-client-manifest.json
和 vue-ssr-server-bundle.json
webpack 需要對原始碼打包兩次,一次是為客戶端環境打包的,一次是為服務端環境打包的。
為客戶端環境打包的檔案,和以前我們打包的資源一樣,不過多出了一個 vue-ssr-client-manifest.json
檔案。服務端環境打包只輸出一個 vue-ssr-server-bundle.json
檔案。
vue-ssr-client-manifest.json
包含了客戶端環境所需的資源名稱:
從上圖中可以看到有三個關鍵詞:
- all,表示這是打包的所有資源。
- initial,表示首頁載入必須的資源。
- async,表示需要非同步載入的資源。
vue-ssr-server-bundle.json
檔案:
- entry, 服務端入口檔案。
- files,服務端依賴的資源。
填坑記錄
1. [vue-router] failed to resolve async component default: referenceerror: window is not defined
由於在一些檔案或第三方檔案中可能會用到 window 物件,並且 node 中不存在 window 物件,所以會報錯。
此時可在 src/app.js
檔案加上以下程式碼進行判斷:
// 在 app.js 檔案新增上這段程式碼,對環境進行判斷
if (typeof window === 'undefined') {
global.window = {}
}
2. mini-css-extract-plugin
外掛造成 ReferenceError: document is not defined
使用 mini-css-extract-plugin
外掛打包的的 server bundle
, 會使用到 document。由於 node 環境中不存在 document 物件,所以報錯。
解決方案:樣式相關的 loader 不要放在 webpack.base.config.js
檔案,將其分拆到 webpack.client.config.js
和 webpack.client.server.js
檔案。其中 mini-css-extract-plugin
外掛要放在 webpack.client.config.js
檔案配置。
base
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
compilerOptions: {
preserveWhitespace: false
}
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|svg|jpg|gif|ico)$/,
use: ['file-loader']
},
{
test: /\.(woff|eot|ttf)\??.*$/,
loader: 'url-loader?name=fonts/[name].[md5:hash:hex:7].[ext]'
},
]
}
client
module: {
rules: [
{
test: /\.css$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
// 解決 export 'default' (imported as 'mod') was not found
esModule: false,
},
},
'css-loader'
]
}
]
}
server
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
]
}
]
}
3. 開發環境下跳轉頁面樣式不生效,但生產環境正常。
由於開發環境使用的是 memory-fs
外掛,打包檔案是放在記憶體中的。如果此時 dist
資料夾有剛才打包留下的資源,就會使用 dist
資料夾中的資源,而不是記憶體中的資源。並且開發環境和打包環境生成的資源名稱是不一樣的,所以就造成了這個 BUG。
解決方法是執行 npm run dev
時,刪除 dist
資料夾。所以要在 npm run dev
對應的指令碼中加上 rimraf dist
。
"dev": "rimraf dist && node ./server/dev-server.js --mode development",
4. [vue-router] Failed to resolve async component default: ReferenceError: document is not defined
不要在有可能使用到服務端渲染的頁面訪問 DOM,如果有這種操作請放在 mounted()
鉤子函式裡。
如果你引入的資料或者介面有訪問 DOM 的操作也會報這種錯,在這種情況下可以使用 require()
。因為 require()
是執行時載入的,所以可以這樣使用:
<script>
// 原來報錯的操作,這個介面有 DOM 操作,所以這樣使用的時候在服務端會報錯。
import { fetchArticles } from '@/api/client'
export default {
methods: {
getAppointArticles() {
fetchArticles({
tags: this.tags,
pageSize: this.pageSize,
pageIndex: this.pageIndex,
})
.then(res => {
this.$store.commit('setArticles', res)
})
},
}
}
</script>
修改後:
<script>
// 先定義一個外部變數,在 mounted() 鉤子裡賦值
let fetchArticles
export default {
mounted() {
// 由於服務端渲染不會有 mounted() 鉤子,所以在這裡可以保證是在客戶端的情況下引入介面
fetchArticles = require('@/api/client').fetchArticles
},
methods: {
getAppointArticles() {
fetchArticles({
tags: this.tags,
pageSize: this.pageSize,
pageIndex: this.pageIndex,
})
.then(res => {
this.$store.commit('setArticles', res)
})
},
}
}
</script>
修改後可以正常使用。
5. 開發環境下,開啟伺服器後無任何反應,也沒見控制檯輸出報錯資訊。
這個坑其實是有報錯資訊的,但是沒有輸出,導致以為沒有錯誤。
在 setup-dev-server.js
檔案中有一行程式碼 if (stats.errors.length) return
,如果有報錯就直接返回,不執行後續的操作。導致伺服器沒任何反應,所以我們可以在這打一個 console.log
語句,列印報錯資訊。
小結
這個 DEMO 是基於官方 DEMO vue-hackernews-2.0 改造的。不過官方 DEMO 發表於 4 年前,最近修改時間是 2 年前,很多選項引數已經過時了。並且官方 DEMO 需要翻牆才能使用。所以我在此基礎上對其進行了改造,改造後的 DEMO 放在 Github 上,它是一個比較完善的 DEMO,可以在此基礎上進行二次開發。
如果你不僅僅滿足於一個 DEMO,建議看一看我的個人部落格專案,它原來是客戶端渲染的專案,後來重構為服務端渲染,絕對實戰。