手把手教你搭建 Vue 服務端渲染專案

譚光志發表於2020-11-02

建議先閱讀官方指南——Vue.js 伺服器端渲染指南,再回到本文開始閱讀。

本文將分成以下兩部分:

  1. 簡述 Vue SSR 過程
  2. 從零開始搭建 SSR 專案

好了,下面開始正文。

簡述 Vue SSR 過程

客戶端渲染過程

  1. 訪問客戶端渲染的網站。
  2. 伺服器返回一個包含了引入資源語句和 <div id="app"></div> 的 HTML 檔案。
  3. 客戶端通過 HTTP 向伺服器請求資源,當必要的資源都載入完畢後,執行 new Vue() 開始例項化並渲染頁面。

服務端渲染過程

  1. 訪問服務端渲染的網站。
  2. 伺服器會檢視當前路由元件需要哪些資原始檔,然後將這些檔案的內容填充到 HTML 檔案。如果有 asyncData() 函式,就會執行它進行資料預取並填充到 HTML 檔案裡,最後返回這個 HTML 頁面。
  3. 當客戶端接收到這個 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 個:

  1. webpack.base.config.js,基礎配置檔案,客戶端與服務端都需要它。
  2. webpack.client.config.js,客戶端配置檔案,用於生成客戶端所需的資源。
  3. 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 屬性是打包時分割程式碼用的。它的作用是將第三方庫都打包在一起。

其他外掛作用:

  1. MiniCssExtractPlugin 外掛, 將 css 提取出來單獨打包。
  2. CssMinimizerPlugin 外掛,壓縮 css。
  3. CompressionPlugin 外掛,將資源壓縮成 gzip 格式(大大提升傳輸效率)。另外還需要在 node 伺服器上引入 compression 外掛配合使用。
  4. 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.jsonvue-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-middlewarewebpack-hot-middleware。顧名思義,看名稱就知道它們的作用,

webpack-dev-middleware 的作用是生成一個與 webpack 的 compiler 繫結的中介軟體,然後在 express 啟動的 app 中呼叫這個中介軟體。

這個中介軟體的作用呢,簡單總結為以下三點:通過watch mode,監聽資源的變更,然後自動打包; 快速編譯,走記憶體;返回中介軟體,支援express 的 use 格式。

webpack-hot-middleware 外掛的作用就是熱更新,它需要配合 HotModuleReplacementPluginwebpack-dev-middleware 一起使用。

打包檔案 vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json

webpack 需要對原始碼打包兩次,一次是為客戶端環境打包的,一次是為服務端環境打包的。

為客戶端環境打包的檔案,和以前我們打包的資源一樣,不過多出了一個 vue-ssr-client-manifest.json 檔案。服務端環境打包只輸出一個 vue-ssr-server-bundle.json 檔案。

vue-ssr-client-manifest.json 包含了客戶端環境所需的資源名稱:

從上圖中可以看到有三個關鍵詞:

  1. all,表示這是打包的所有資源。
  2. initial,表示首頁載入必須的資源。
  3. async,表示需要非同步載入的資源。

vue-ssr-server-bundle.json 檔案:

  1. entry, 服務端入口檔案。
  2. 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.jswebpack.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,建議看一看我的個人部落格專案,它原來是客戶端渲染的專案,後來重構為服務端渲染,絕對實戰。

參考資料

更多文章,敬請關注

相關文章