vue的兩種伺服器端渲染方案

2023-02-27 12:01:47

作者:京東零售 姜欣

關於伺服器端渲染方案,之前只接觸了基於react的Next.js,最近業務開發vue用的比較多,所以調研了一下vue的伺服器端渲染方案。 首先:長文預警,下文包括了兩種方案的實踐,沒有耐心的小夥伴可以直接跳到方案標題下,down程式碼體驗一下。

前置知識:

1、什麼是伺服器端渲染(ssr)?

簡單來說就是使用者第一次請求頁面時,頁面上的內容是通過伺服器端渲染生成的,瀏覽器直接顯示伺服器端返回的完整html就可以,加快首屏顯示速度。

舉個栗子:

當我們存取一個商品列表時,如果使用使用者端渲染(csr),瀏覽器會載入空白的頁面,然後下載js檔案,通過js在使用者端請求資料並渲染頁面。如果使用伺服器端渲染(ssr),在請求商品列表頁面時,伺服器會獲取所需資料並將渲染後的HTML傳送給瀏覽器,瀏覽器一步到位直接展示,而不用等待資料載入和渲染,提高使用者的首屏體驗。

2、伺服器端渲染的優缺點

優點:

(1)更好的seo:抓取工具可以直接檢視完全渲染的頁面。現在比較常用的互動是頁面初始展示 loading 菊花圖,然後通過非同步請求獲取內容,但是但抓取工具並不會等待非同步完成後再行抓取頁面內容。

(2)內容到達更快:不用等待所有的 js 都完成下載並執行,所以使用者會更快速地看到完整渲染的頁面。

缺點:

(1)伺服器渲染應用程式,需要處於 Node.js server 執行環境

(2)開發成本比較高

總結:

總得來說,決定是否使用伺服器端渲染,取決於具體的業務場景和需求。對於具有大量靜態內容的簡單頁面,使用者端渲染更合適一些,因為它可以更快地載入頁面。但是對於需要從伺服器動態載入資料的複雜頁面,伺服器端渲染可能是一個更好的選擇,因為他可以提高使用者的首屏體驗和搜尋引擎優化。

下面進入正文

方案一:vue外掛vue-server-render

git 範例demo地址

結論前置:不建議用,設定成本高

官網地址: https://v2.ssr.vuejs.org/zh/

首先要吐槽一下官網,按官網教學比較難搞,目錄安排的不太合理,一頓操作專案都沒起來...

並且官網範例的構建設定程式碼是webpack4的,現在初始化專案後基本安裝的都是webpack5,有一些語法不同

(1)首先,先初始化一個npm專案,然後安裝依賴得到一個基礎專案 。(此處要注意vue-server-renderer 和 vue 必須匹配版本)

npm init -y
yarn add vue vue-server-renderer -S
yarn add express -S
yarn add webpack webpack-cli friendly-errors-webpack-plugin vue-loader babel-loader @babel/core url-loader file-loader vue-style-loader css-loader sass-loader sass webpack-merge webpack-node-externals -D
yarn add clean-webpack-plugin @babel/preset-env -D
yarn add rimraf // 模擬linx的刪除命令,在build時先刪除dist
yarn add webpack-dev-middleware webpack-hot-middleware -D
yarn add chokidar  -D //監聽變化
yarn add memory-fs -D
yarn add nodemon -D
...實在太多,如有缺失可以在package.json中查詢
另外:我現在用的"vue-loader": "^15.9.0"版本,之前用的是"vue-loader": "^17.0.1",報了一個styles的錯

(2)設定app.js,entry-client.js,entry-server.js,將官網參考中的範例程式碼(傳送門: 構建設定 )拷貝至對應檔案。

app.js

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import { sync } from 'vuex-router-sync'

// 匯出一個工廠函數,用於建立新的
// 應用程式、router 和 store 範例
export function createApp () {
    // 建立 router 和 store 範例
    const router = createRouter()
    const store = createStore()
    
    sync(store, router)

    const app = new Vue({
        router,
        store,
        render: h => h(App)
    })

    return { app, router, store }
}

entry-client.js

import Vue from 'vue'
import { createApp } from './app'

Vue.mixin({
    beforeMount () {
        const { asyncData } = this.$options
        if (asyncData) {
            this.dataPromise = asyncData({
                store: this.$store,
                route: this.$route
            })
        }
    }
})

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
    // 在初始路由 resolve 後執行,
    // 使用 `router.beforeResolve()`,以便確保所有非同步元件都 resolve。
    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))
        })

        if (!activated.length) {
            return next()
        }

        Promise.all(activated.map(c => {
            if (c.asyncData) {
                return c.asyncData({ store, route: to })
            }
        })).then(() => {
            next()
        }).catch(next)
    })

    app.$mount('#app')
})

entry-server.js

import { createApp } from './app'

export default context => {
    // 返回一個promise,伺服器能夠等待所有的內容在渲染前,已經準備就緒,
    return new Promise((resolve, reject) => {
        const { app, router, store } = createApp()
        
        router.push(context.url)

        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents()
            if (!matchedComponents.length) {
                return reject({ code: 404 })
            }

            // 對所有匹配的路由元件呼叫 `asyncData()`
            Promise.all(matchedComponents.map(Component => {
                if (Component.asyncData) {
                    return Component.asyncData({
                        store,
                        route: router.currentRoute
                    })
                }
            })).then(() => {
                context.state = store.state

                resolve(app)
            }).catch(reject)
        }, reject)
    })
}

(3)在根目錄下建立server.js 檔案

其中一個非常重要的api:createBundleRenderer,這個api上面有一個方法renderToString將程式碼轉化成html字串,主要功能就是把用webpack把打包後的伺服器端程式碼渲染出來。具體瞭解可看官網bundle renderer指引(傳送門: bundle renderer指引

// server.js
const app = require('express')()
const { createBundleRenderer } = require('vue-server-renderer')
const fs = require('fs')
const path = require('path')
const resolve = file => path.resolve(__dirname, file)

const isProd = process.env.NODE_ENE === "production"

const createRenderer = (bundle, options) => {
    return createBundleRenderer(bundle, Object.assign(options, {
        basedir: resolve('./dist'),
        runInNewContext: false,
    }))
}

let renderer, readyPromise
const templatePath = resolve('./src/index.template.html')
if (isProd) {
    const bundle = require('./dist/vue-ssr-server-bundle.json')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    const template = fs.readFileSync(templatePath, 'utf-8')

    renderer = createRenderer(bundle, {
        // 推薦
        template, // (可選)頁面模板
        clientManifest // (可選)使用者端構建 manifest
    })
} else {
    // 開發模式
    readyPromise = require('./config/setup-dev-server')(app, templatePath, (bundle, options) => {
        renderer = createRenderer(bundle, options)
    })
}

const render = (req, res) => {
    const context = {
        title: 'hello ssr with webpack',
        meta: `
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
    `,
        url: req.url
    }
    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)
        }
    })
}

// 在伺服器處理常式中……
app.get('*', isProd ? render : (req, res) => {
    readyPromise.then(() => render(req, res))
})

app.listen(8080) // 監聽的是8080埠

(4)接下來是config設定

在根目錄新增config資料夾,然後新增四個組態檔:webpack.base.config,webpack.client.config,webpack.server.config,setup-dev-server(此方法是一個封裝,為了設定個熱載入,差點沒搞明白,參考了好多)(官網傳送門: 構建設定

大部分官網有範例程式碼,但是要在基礎上進行一些更改

webpack.base.config

// webpack.base.config
const path = require('path')
// 用來處理字尾為.vue的檔案
const { VueLoaderPlugin } = require('vue-loader')
const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
// 定位到根目錄
const resolve = (dir) => path.join(path.resolve(__dirname, "../"), dir)

// 打包時會先清除一下
// const { CleanWebpackPlugin } = require('clean-webpack-plugin')

const isProd = process.env.NODE_ENV === "production"

module.exports = {
    mode: isProd ? 'production' : 'development',
    output: {
        path: resolve('dist'),
        publicPath: '/dist/',
        filename: '[name].[chunk-hash].js'
    },
    resolve: {
        alias: {
            'public': resolve('public')
        }
    },
    module: {
        noParse: /es6-promise.js$/,
        rules: [
            {
                test: /.vue$/,
                loader: 'vue-loader',
                options: {
                    compilerOptions: {
                        preserveWhiteSpace: false
                    }
                }
            },
            {
                test: /.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /.(png|jpg|gif|svg)$/,
                loader: 'url-loader',
                options: {
                    limit: 10000,
                    name: '[name].[ext]?[hash]'
                }
            },
            {
                test: /.s(a|c)ss?$/,
                use: ['vue-style-loader', 'css-loader', 'sass-loader']
            }
        ]
    },
    performance: {
        hints: false
    },
    plugins:[
        new VueLoaderPlugin(),
        // 編譯後的友好提示,比如編譯完成或者編譯有錯誤
        new FriendlyErrorsWebpackPlugin(),
        // 打包時會先清除一下
        // new CleanWebpackPlugin()
    ]
}

webpack.client.config

// webpack.client.config
const {merge} = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
    entry: {
        app: './src/entry-client.js'
    },
    optimization: {
        // 重要資訊:這將 webpack 執行時分離到一個引導 chunk 中,
        // 以便可以在之後正確注入非同步 chunk。
        // 這也為你的 應用程式/vendor 程式碼提供了更好的快取。
        splitChunks: {
            name: "manifest",
            minChunks: Infinity
        }
    },
    plugins: [
        // 此外掛在輸出目錄中
        // 生成 `vue-ssr-client-manifest.json`。
        new VueSSRClientPlugin()
    ]
})

webpack.server.config

// webpack.server.config
const {merge} = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')

// webpack的基礎設定,比如sass,less預編譯等
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseConfig, {
    // 將 entry 指向應用程式的 server entry 檔案
    entry: './src/entry-server.js',

    target: 'node',

    // 對 bundle renderer 提供 source map 支援
    devtool: 'source-map',

    // 此處告知 server bundle 使用 Node 風格匯出模組(Node-style exports)
    output: {
        libraryTarget: 'commonjs2'
    },

    // https://webpack.js.org/configuration/externals/#function
    // https://github.com/liady/webpack-node-externals
    // 外接化應用程式依賴模組。可以使伺服器構建速度更快,
    // 並生成較小的 bundle 檔案。
    externals: nodeExternals({
        // 不要外接化 webpack 需要處理的依賴模組。
        // 你可以在這裡新增更多的檔案型別。例如,未處理 *.vue 原始檔案,
        // 你還應該將修改 `global`(例如 polyfill)的依賴模組列入白名單
        allowlist: /.css$/
    }),

    // 這是將伺服器的整個輸出
    // 構建為單個 JSON 檔案的外掛。
    // 預設檔名為 `vue-ssr-server-bundle.json`
    plugins: [
        new VueSSRServerPlugin()
    ]
})

setup-dev-server:封裝createRenderer方法

const webpack = require('webpack')
const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar')
const middleware = require("webpack-dev-middleware")
const HMR = require("webpack-hot-middleware")
const MFS = require('memory-fs')

const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')

const readFile = (fs, file) => {
    try {
        return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf8')
    } catch (error) {

    }
}

const setupServer = (app, templatePath, cb) => {
    let bundle
    let clientManifest
    let template
    let ready
    const readyPromise = new Promise(r => ready = r)

    template = fs.readFileSync(templatePath, 'utf8')
    const update = () => {
        if (bundle && clientManifest) {
            // 通知 server 進行渲染
            // 執行 createRenderer -> RenderToString
            ready()
            cb(bundle, {
                template,
                clientManifest
            })
        }
    }
    // webpack -> entry-server -> bundle
    const mfs = new MFS();
    const serverCompiler = webpack(serverConfig);

    serverCompiler.outputFileSystem = mfs;
    serverCompiler.watch({}, (err, stats) => {
        if (err) throw err
        // 之後讀取輸出:
        stats = stats.toJson()
        stats.errors.forEach(err => console.error(err))
        stats.warnings.forEach(err => console.warn(err))
        if (stats.errors.length) return
        bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
        update()
    });

    clientConfig.plugins.push(
        new webpack.HotModuleReplacementPlugin()
    )
    clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
    clientConfig.output.filename = '[name].js'

    const clientCompiler = webpack(clientConfig);

    const devMiddleware = middleware(clientCompiler, {
        noInfo: true, publicPath: clientConfig.output.publicPath, logLevel: 'silent'
    })
    app.use(devMiddleware);

    app.use(HMR(clientCompiler));

    clientCompiler.hooks.done.tap('clientsBuild', stats => {
        stats = stats.toJson()
        stats.errors.forEach(err => console.error(err))
        stats.warnings.forEach(err => console.warn(err))
        if (stats.errors.length) return
        clientManifest = JSON.parse(readFile(
            devMiddleware.fileSystem,
            'vue-ssr-client-manifest.json'
        ))
        update()
    })

    // fs -> templatePath -> template
    chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf8')
        console.log('template is updated');
        update()
    })

    return readyPromise
}

module.exports = setupServer

(5)設定搞完了接下來是程式碼渲染

在src目錄下,新增index.template.html檔案,將官網中的例子(地址:使用一個頁面模板 )複製,並進行一些更改

<html>
<head>
    <!-- 使用雙花括號(double-mustache)進行 HTML 跳脫插值(HTML-escaped interpolation) -->
    <title>{{ title }}</title>

    <!-- 使用三花括號(triple-mustache)進行 HTML 不跳脫插值(non-HTML-escaped interpolation) -->
    {{{ meta }}}
</head>
<body>
<!--這個是告訴我們在哪裡插入正文的內容-->
<!--vue-ssr-outlet-->
</body>
</html>

(6)再搞個store和api模擬一下資料請求

這裡介紹一下一個很重要的東西asyncData 預取資料,預取資料是在vue掛載前,所以下文這裡用了上下文來獲取store而不是this

asyncData: ({ store }) => { return store.dispatch('getDataAction') },

在src下建立api資料夾,並在下面建立data.js檔案

// data.js
const getData = () => new Promise((resolve) => {
    setTimeout(() => {
        resolve([
            {
                id: 1,
                item: '測試1'
            },
            {
                id: 2,
                item: '測試2'
            },
        ])
    }, 1000)
})

export {
    getData
}

在src下建立store資料夾,並在下面建立index.js檔案

// store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

import { getData } from '../api/data'

export function createStore () {
    return new Vuex.Store({
        state: {
            lists: []
        },
        actions: {
            getDataAction ({ commit }) {
                return getData().then((res) => {                   
                    commit('setData', res)
                })
            }
        },
        mutations: {
            setData (state, data) {
                state.lists = data
            }
        }
    })
}

(7)編寫元件,在src/components資料夾下寫兩個元件,在app.vue中參照一下,用上剛寫的模擬資料

Hello.vue

<template>
  <div>
    這裡是測試頁面一
    <p>{{item}}</p>
    <router-link to="/hello1">連結到測試頁面二</router-link>
  </div>
</template>

<script>
export default {
  asyncData: ({ store }) => {
    return store.dispatch('getDataAction')
  },
  computed: {
    item () {
      return this.$store.state.lists
    }
  }
}
</script>

<style lang="scss" scoped>
</style>

Hello1.vue

<template>
  <div>這裡是測試頁面二{{item}}</div>
</template>

<script>
export default {
  asyncData: ({ store }) => {
    return store.dispatch('getDataAction')
  },
  computed: {
    item () {
      return this.$store.state.lists
    }
  }
}
</script>

<style lang="scss" scoped>
</style>

(8)設定路由並在app.vue使用路由

router.js

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

export function createRouter () {
    return new Router({
        mode: 'history',
        routes: [
            {
                path: '/hello',
                component: () => import('./components/Hello.vue')
            },
            {
                path: '/hello1',
                component: () => import('./components/Hello1.vue')
            },
        ]
    })
} 

app.vue

<template>
  <div id="app">
    <router-view></router-view>
  </div>
</template>

<script>

export default {
  name: 'App',
}
</script>

<style lang="scss" scoped>
</style>

(9)根目錄下建立一個.babelrc,進行設定

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ]
}

(10)改寫package.json執行命令

"dev": "nodemon server.js",
"build": "rimraf dist && npm run build:client && npm run build:server",
"build:client": "webpack --config config/webpack.client.config.js",
"build:server": "webpack --config config/webpack.server.config.js"

大搞告成,執行一下dev命令,可以通過存取localhost:8080埠看到頁面,記得帶上路由哦~

執行build命令可看到,最後dist檔案下共有三個檔案:main.[chunk-hash].js,vue-ssr-client-manifest.json,vue-ssr-server-bundle.json

附上檔案整體目錄結構

方案二:基於vue的nuxt.js通用應用框架

git 範例demo地址

一對比,這個就顯得絲滑多了~ 官網地址: nuxt.js

先對比一下兩種方案的差別

1.vue初始化雖然有cli,但是nuxt.js的cli更加完備
2.nuxt有更合理的工程化目錄,vue過於簡潔,比如一些component,api資料夾都是要手動建立的
3.路由設定:傳統應用需要自己來設定,nuxt.js自動生成
4.沒有統一設定,需手動建立。nuxt.js會生成nuxt.config.js
5.傳統不易與管理底層框架邏輯(nuxt支援中介軟體管理,雖然我還沒探索過這裡)

顯而易見這個上手就快多了,也不需要安裝一大堆依賴,如果用了sass需要安裝sass和sass-loader,反正我是用了

(1)建立一個專案 可選npm,npx,yarn,具體看官方檔案

npm init nuxt-app <project-name>

(2)pages下面建立幾個檔案

nuxt是通過pages頁面形成動態的路由,不用手動設定路由。比如在pages下面新增了個檔案about.vue,那麼這個頁面對應的路由就是/about

其實這個時候執行npm run dev 就可以看到簡單的頁面了

(3)模擬介面

這裡介紹一個外掛,可以快速建立一個服務

npm i json-server 

安裝完後,在根目錄新增db.json檔案,模擬幾個介面

{
  "post": [{"id": 1, "title": "json-server", "author": "jx"}],
  "comments": [{"id": 1, "body": "some comment", "postId": 1}],
  "profile": {"name": "typicode"}
}

執行命令json-server --watch db.json --port=8000(不加會埠衝突),就可以看到

因為是get請求,可以直接點選存取可以看到mock的資料已經返回了

(4)頁面呼叫

先設定一下axios,推薦使用nuxt.js封裝的axios:"@nuxtjs/axios": "^5.13.6",然後再在nuxt.config.js檔案中modules下面設定一下就可以使用了

modules: [  '@nuxtjs/axios'],

隨便找個介面呼叫一下

<template>
  <div>
    <div>
      這裡是測試頁面一
    </div>
    介面返回資料:{{posts}}
  </div>
</template>

<script>
export default {
  name: 'IndexPage',
  async asyncData({$axios}){
    const result = await $axios.get('http://localhost:8000/post')
    return{
      posts: result.data
    }
  }
}
</script>

重新整理下頁面就可以看到效果了,這裡注意$axios有兩個get方法,一個$axios.get還會返回頭部等資訊,另一個$axios.$get只返回結果

總結:

從頁面篇幅上應該也能看到哪個容易上手了,nuxt相對於外掛來說限定了資料夾的結構,並通過此預定了一些功能,更好上手。預設了利用vue.js開發伺服器端渲染所需要的各種設定,並且提供了提供了靜態站點,非同步資料載入,中介軟體支援,佈局支援等