vue-ssr在專案中的實踐

java06051515發表於2019-12-06

vue-ssr在專案中的實踐

[TOC]

寫在文前

由於前端腳手架、打包工具、Node等版本的多樣性,本文無法同時兼顧,文中所述皆基於以下技術棧進行。

腳手架:vue-cli3

打包工具:webpack4,整合在vue-cli3中,通過修改vue.config.js的方式進行配置

Node框架:koa2

簡介

​ 伺服器端渲染,即採用“同構”的策略,在伺服器端對一部分前端程式碼進行渲染,減少瀏覽器對頁面的渲染量。

通常伺服器端渲染的優點和用途有以下幾點:

1.更好的SEO

2.更快的頁面載入速度

3.在伺服器端完成資料的載入

​ 但需要注意,在伺服器端渲染提高客戶端效能的同時,也帶來了更高的伺服器負荷的問題。在專案開發時需要權衡其優點及缺點。

Vue專案中如何實現伺服器端渲染?

在做Vue-ssr之前的一些思考

1.Vue在頁面渲染時以Vue例項為基本單元,在伺服器端進行渲染時,是否也應對Vue例項進行渲染?

2.使用者與客戶端的關係是一對一,而與伺服器端的關係是多對一,如何避免多個使用者之間在伺服器端的資料共享的問題?

3.如何實現同構策略?即讓伺服器端能夠執行前端的程式碼?

4.伺服器端渲染的Vue專案,開發環境和生產環境分別應該如何部署?有何區別?

5.如何保證伺服器端渲染改造後的程式碼仍能通過訪問靜態資源的方式直接訪問到?

對於這些思考,將在文末進行回顧。

具體實現方案

Vue官方提供了【vue-server-renderer】包實現Vue專案的伺服器渲染,安裝方式如下:

npm install vue-server-renderer --save

在使用vue-server-renderer時需要注意以下一些問題:

1.vue-server-renderer版本須與vue保持一致

2.vue-server-renderer只能在node端進行執行,推薦node.js6+版本

一、最簡單的實現

​ vue-server-renderer為我們提供了一個【createRenderer】方法,支援對單一Vue例項進行渲染,並輸出渲染後的html字串或node可讀的stream流。

// 1.建立Vue例項
const Vue = require('vue');
const app = new Vue({
  template: '<div></div>',
});
// 2.引入renderer方法
const renderer = require('vue-server-renderer').createRenderer();
// 3-1.將Vue例項渲染為html字串
renderer.renderToString(app, (err, html) => {});
// or
renderer.renderToString(app).then((html) => {}, (err) => {});
// 3-2.將Vue例項渲染為stream流
const renderStream = renderer.renderToStream(app);
// 通過訂閱事件,在回撥中進行操作
// event可取值'data'、'beforeStart'、'start'、'beforeEnd'、'end'、'error'等
renderStream.on(event, (res) => {});

​ 但通常情況下,我們沒有必要在伺服器端建立Vue例項並進行渲染,而是需要對前端的Vue專案中每個SPA的Vue例項進行渲染,基於此,vue-server-renderer為我們提供了一套如下的伺服器端渲染方案。

二、完整的實現

完整的實現流程如下圖所示分為【模板頁】(HTML)、【客戶端】(Client Bundle)、【伺服器端】(Server Bundle)三個模組。三個模組功能如下:

模板頁:提供給客戶端和伺服器端渲染的html框架,令客戶端和伺服器端在該框架中進行頁面的渲染

客戶端:僅在瀏覽器端執行,向模板頁中注入js、css等靜態資源

伺服器端:僅在伺服器端執行,將Vue例項渲染為html字串,注入到模板頁的對應位置中

æ¶æ

整個服務的構建流程分為以下幾步:

1.通過webpack將Vue應用打包為瀏覽器端可執行的客戶端Bundle;

2.通過webpack將Vue應用打包為Node端可執行的伺服器端Bundle;

3.Node端呼叫伺服器端Bundle渲染Vue應用,並將渲染好的html字串以及客戶端Bundle傳送至瀏覽器;

4.瀏覽器端接收到後,呼叫客戶端Bundle向頁面注入靜態資源,並與伺服器端渲染好的頁面進行匹配。

需要注意的是,客戶端與伺服器端渲染的內容需要匹配才能進行正常的頁面載入,一些頁面載入異常問題將在下文進行具體描述。

三、具體程式碼實現

1、Vue應用程式改造

​ SPA模式下,使用者與Vue應用是一對一的關係,而在SSR模式下,由於Vue例項是在伺服器端進行渲染,而伺服器是所有使用者共用的,使用者與Vue應用的關係變為了多對一。這就導致多個使用者共用同一個Vue例項,導致例項中的資料相互汙染。

​ 針對這個問題,我們需要對Vue應用的入口進行改造,將Vue例項的建立改為“工廠模式”,在每次渲染的時候建立新的Vue例項,避免使用者共用同一個Vue例項的情況。具體改造程式碼如下:

// router.js
import Vue from 'vue';
import Router from 'vue-router';
Vue.use(Router);
export function createRouter() {
    return new Router({
        mode: 'history',
        routes: [],
    });
}
// store.js
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
export function createStore() {
    return new Vuex.Store({
        state,
        actions: {},
        mutations: {},
        modules: {},
    });
}
// main.js
import Vue from 'vue';
import App from './App.vue';
import {createRouter} from './router';
import {createStore} from './store';
export function createApp() {
    const router = createRouter();
    const store = createStore();
    const app = new Vue({
        router,
        store,
        render: (h) => h(App),
    });
    return {app, router, store};
}

​ 需要注意的是,我們需要將vue-router、vuex等Vue例項內部使用的模組也配置為“工廠模式”,避免路由、狀態等在多個Vue例項間共用。

​ 同時,由於我們在SSR過程中需要使用到客戶端和伺服器端兩個模組,因此需要配置客戶端、伺服器端兩個入口。

客戶端入口配置如下:

// entry-client.js
import {createApp} from './main';
const {app, router, store} = createApp();
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
    app.$mount('#app');
});

​ 在上文中我們提到,客戶端Bundle的功能是在瀏覽器端接收到伺服器渲染好的html字串後,向頁面中注入靜態資源以及頁面的二次渲染工作,因此我們在Vue應用的客戶端入口中,只需像之前一樣將Vue例項掛載到指定的html標籤上即可。

​ 同時,伺服器端在渲染時如果有資料預取操作,會將store中的資料先注入到【window.__INITIAL STATE\_】,在客戶端中,我們需要將window.__INITIAL STATE\_中的值重新賦給store。

伺服器端入口配置如下:

// entry-server.js
import {createApp} from './main';
export default (context) => {
    return new Promise((resolve, reject) => {
        const {app, router, store} = createApp();
        // 設定伺服器端 router 的位置
        router.push(context.url);
        // 等到 router 將可能的非同步元件和鉤子函式解析完
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,執行 reject 函式,並返回 404
            if (!matchedComponents.length) {
                return reject({
                    code: 404
                });
            }
            Promise.all(matchedComponents.map((Component) => {
                if (Component.extendOptions.asyncData) {
                    const result = Component.extendOptions.asyncData({
                        store,
                        route: router.currentRoute,
                              options: {},
                    });
                    return result;
                }
            })).then(() => {
                // 狀態將自動序列化為 window.__INITIAL_STATE__,並注入 HTML。
                context.state = store.state;
                resolve(app);
            }).catch(reject);
        }, reject);
    });
};

​ 伺服器端需要根據使用者的請求,動態匹配需要渲染的Vue元件,並設定router和store等模組。

​ 對於router,只需呼叫vue-router的push方法進行路由切換即可;

​ 對於store,則需要檢測並呼叫Vue元件中的【asyncData】方法進行store的初始化,並將初始化後的state賦值給上下文,伺服器在進行渲染時會將上下文中的state序列化為window.__INITIAL STATE\_,並注入到html中。對於資料預取的操作和處理,我們將在下文【伺服器端資料預取】一節進行具體介紹。

2、Webpack打包邏輯配置

​ 由於伺服器端渲染服務需要客戶端Bundle和伺服器端Bundle兩個包,因此需要利用webpack進行兩次打包,分別打包客戶端和伺服器端。這裡我們可以通過shell指令碼進行打包邏輯的編寫:

#!/bin/bash
set -e
echo "刪除舊dist檔案"
rm -rf dist
echo "打包SSR伺服器端"
export WEBPACK_TARGET=node && vue-cli-service build
echo "將伺服器端Json檔案移出dist"
mv dist/vue-ssr-server-bundle.json bundle
echo "打包SSR客戶端"
export WEBPACK_TARGET=web && vue-cli-service build
echo "將伺服器端Json檔案移回dist"
mv bundle dist/vue-ssr-server-bundle.json

​ 在shell命令中,我們配置了【WEBPACK_TARGET】這一環境變數,為webpack提供可辨別客戶端/伺服器端打包流程的標識。

​ 同時,vue-server-renderer為我們提供了【server-plugin】和【client-plugin】兩個webpack外掛,用於分別打包伺服器端和客戶端Bundle。以下是webpack配置檔案中,使用這兩個外掛進行打包的具體配置:

// vue.config.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const merge = require('lodash.merge');
const TARGET_NODE = process.env.WEBPACK_TARGET === 'node';
const entry = TARGET_NODE ? 'server' : 'client';
const isPro = process.env.NODE_ENV !== 'development';
module.exports = {
      /**
       * 靜態資源在請求時,如果請求路徑為相對路徑,則會基於當前域名進行訪問
     * 在本地開發時,為保證靜態資源的正常載入,在8080埠啟動一個靜態資源伺服器
     * 該處理將會在第四小節《Node端開發環境配置》中進行詳細介紹
     */
    publicPath: isPro ? '/' : 'http://127.0.0.1:8080/',
    outputDir: 'dist',
    pages: {
        index: {
            entry: `src/pages/index/entry-${entry}.js`,
            template: 'public/index.html'
        }
    },
    css: {
        extract: isPro ? true : false,
    },
    chainWebpack: (config) => {
          // 關閉vue-loader中預設的伺服器端渲染函式
        config.module
            .rule('vue')
            .use('vue-loader')
            .tap((options) => {
                merge(options, {
                    optimizeSSR: false,
                });
            });
    },
    configureWebpack: {
          // 需要開啟source-map檔案對映,因為伺服器端在渲染時,
          // 會通過Bundle中的map檔案對映關係進行檔案的查詢
        devtool: 'source-map',
          // 伺服器端在Node環境中執行,需要打包為類Node.js環境可用包(使用Node.js require載入chunk)
          // 客戶端在瀏覽器中執行,需要打包為類瀏覽器環境裡可用包
        target: TARGET_NODE ? 'node' : 'web',
          // 關閉對node變數、模組的polyfill
        node: TARGET_NODE ? undefined : false,
        output: {
              // 配置模組的暴露方式,伺服器端採用module.exports的方式,客戶端採用預設的var變數方式
            libraryTarget: TARGET_NODE ? 'commonjs2' : undefined,
        },
          // 外接化應用程式依賴模組。可以使伺服器構建速度更快
        externals: TARGET_NODE ? nodeExternals({
            whitelist: [/\.css$/],
        }) : undefined,
        plugins: [
              // 根據之前配置的環境變數判斷打包為客戶端/伺服器端Bundle
            TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin(),
        ],
    },
};

​ 結合webpack配置檔案的程式碼和註釋,我們再回到打包的shell指令碼中梳理打包流程。

1)打包伺服器端Bundle

​ 首先將【WEBPACK_TARGET】環境變數設定為node,webpack會將入口entry設定為伺服器端入口【entry-server.js】,通過外掛【server-plugin】進行打包。

​ 打包後會在dist資料夾下生成【vue-ssr-server-bundle.json】檔案(該名稱為預設名稱,可在外掛中進行設定),該檔案有三個屬性entry、files、maps。其中entry屬性是打包後的入口檔案路徑字串,files屬性是一組打包後的【檔案路徑-檔案內容 鍵值對】,編譯過的檔案的內容都會被存到該json檔案的files屬性中,而maps則是通過【source-map】編譯出的一組檔案資源配置資訊。

// vue-ssr-server-bundle.json
{
      "entry": "js/index.[hash].js",
      "files": {
          "js/index.[hash].js": "",
    },
      "maps": {
          "js/index.[hash].js": {}
    }
}
2)將伺服器端打包後的檔案臨時移出dist資料夾

​ 由於需要進行兩次打包,在打包客戶端的時候會將之前的dist資料夾刪除,為避免伺服器端Bundle丟失,需將其臨時移出dist資料夾。

3)打包客戶端Bundle

​ 在打包客戶端時,將【WEBPACK_TARGET】環境變數修改為web,webpack會將入口entry設定為客戶端入口【entry-client.js】,通過外掛【client-plugin】進行打包。

​ 打包後會在dist資料夾下生成前端專案打包後的靜態資原始檔,以及【vue-ssr-client-manifest.json】檔案,其中靜態資原始檔可部署至伺服器提供傳統SPA服務。而vue-ssr-client-manifest.json檔案中包含publicPath、all、initial、async、modules屬性,其作用分別如下:

publicPath:訪問靜態資源的根相對路徑,與webpack配置中的publicPath一致

all:打包後的所有靜態資原始檔路徑

initial:頁面初始化時需要載入的檔案,會在頁面載入時配置到preload中

async:頁面跳轉時需要載入的檔案,會在頁面載入時配置到prefetch中

modules:專案的各個模組包含的檔案的序號,對應all中檔案的順序

// vue-ssr-client-manifest.json
{
      "publicPath": "/",
      "all": [],
      "initial": [],
      "async": [],
      "modules": {
          "moduleId": [
              fileIndex
        ]
    }
}
4)將臨時移出dist的伺服器端Bundle移回dist資料夾
3、Node端生產環境配置

​ 經過以上幾步打包流程,我們已經將專案打包為【vue-ssr-server-bundle.json】、【vue-ssr-client-manifest.json】、【前端靜態資源】三個部分,之後我們需要在Node端利用打包後的這三個模組內容進行伺服器端渲染工作。

1)Renderer、BundleRenderer的區別

​ vue-server-renderer中存在兩個用於伺服器端渲染的主要類【Renderer】、【BundleRenderer】。

​ 在【最簡單的實現】一節我們提到過【createRenderer】方法,實際上就是建立Renderer物件進行渲染工作,該物件包含renderToString和renderToStream兩個方法,用於將Vue例項渲染成html字串或生成node可讀流。

​ 而在【完整的實現】一節中,我們採用的是將專案打包為客戶端、伺服器端Bundle的方法,此時需要利用vue-server-renderer的另一個方法【createBundleRenderer】,建立BundleRenderer物件進行渲染工作。

// 原始碼中 vue-server-renderer/build.dev.js createBundleRenderer方法
function createBundleRenderer(bundle, rendererOptions) {
  if ( rendererOptions === void 0 ) rendererOptions = {};
  var files, entry, maps;
  var basedir = rendererOptions.basedir;
  // load bundle if given filepath
  if (
    typeof bundle === 'string' &&
    /\.js(on)?$/.test(bundle) &&
    path$2.isAbsolute(bundle)
  ) {
    // 解析bundle檔案
  }
  entry = bundle.entry;
  files = bundle.files;
  basedir = basedir || bundle.basedir;
  maps = createSourceMapConsumers(bundle.maps);
  var renderer = createRenderer(rendererOptions);
  var run = createBundleRunner(
    entry,
    files,
    basedir,
    rendererOptions.runInNewContext
  );
  return {
    renderToString: function (context, cb) {
      run(context).catch((err) => {}).then((app) => {
        renderer.renderToString(app, context, (err, res) => {
          cb(err, res);
        });
      });
    },
    renderToStream: function (context) {
      run(context).catch((err) => {}).then((app) => {
        renderer.renderToStream(app, context);
      });
    }
  }
}

​ 以上createBundleRenderer方法程式碼中可以看到,BundleRenderer物件同樣包含【renderToString】和【renderToStream】兩個方法,但與createRenderer方法不同,它接收的是伺服器端Bundle檔案或檔案路徑。在執行時會先判斷接收的是物件還是字串,如果為字串則將其作為檔案路徑去讀取檔案。在讀取到Bundle檔案後會對【Webpack打包邏輯配置】一節中所說的伺服器端Bundle的相關屬性進行解析。同時構建Renderer物件,呼叫Renderer物件的renderToString和renderToStream方法。

​ 可以看出,BundleRenderer和Renderer的區別,僅在於多一步Bundle解析的過程,而後仍使用Renderer進行渲染。

2)程式碼實現

在瞭解到區別後,我們將在這裡採用BundleRenderer物件進行伺服器端渲染,程式碼如下:

// prod.ssr.js
const fs = require('fs');
const path = require('path');
const router = require('koa-router')();
const resolve = file => path.resolve(__dirname, file);
const { createBundleRenderer } = require('vue-server-renderer');
const bundle = require('vue-ssr-server-bundle.json');
const clientManifest = require('vue-ssr-client-manifest.json');
const renderer = createBundleRenderer(bundle, {
    runInNewContext: false,
    template: fs.readFileSync(resolve('index.html'), 'utf-8'),
    clientManifest,
});
const renderToString = (context) => {
    return new Promise((resolve, reject) => {
        renderer.renderToString(context, (err, html) => {
            err ? reject(err) : resolve(html);
        });
    });
};
router.get('*', async (ctx) => {
    let html = '';
    try {
        html = await renderToString(ctx);
        ctx.body = html;
    } catch(e) {}
});
module.exports = router;

在程式碼中可以看出整個渲染流程分為三步:

​ 1.獲取伺服器端、客戶端、模板檔案,通過createBundleRenderer方法構建BundleRenderer物件;

​ 2.接收到使用者請求,呼叫renderToString方法並傳入請求上下文,此時伺服器端渲染服務會呼叫伺服器端入口檔案entry-server.js進行頁面渲染;

​ 3.將渲染後的html字串配置到response的body中,返回到瀏覽器端。

4、Node端開發環境配置

​ Vue官方只提供了針對Vue例項和打包後的Bundle包進行伺服器端渲染的方案,但在開發環境中我們會面臨以下幾個問題:

1)webpack將打包後的資原始檔存放在了記憶體中,如何獲取到打包後的Bundle的json檔案?

2)如何在開發環境中同時打包和執行客戶端與伺服器端?

​ 在此,我們採用的策略是使用webpack啟動開發環境的前端專案,通過http請求獲取到存在記憶體中的客戶端靜態資源【vue-ssr-client-manifest.json】;同時在Node中,使用【 @vue/cli-service/webpack.config】獲取到伺服器端的webpack配置,利用webpack包直接進行伺服器端Bundle的打包操作,監聽並獲取到最新的【vue-ssr-server-bundle.json】檔案。這樣,我們就獲取到了客戶端與伺服器端檔案,之後的流程則與生產環境中相同。

​ 首先,我們來看一下npm命令的配置:

// package.json
{
        "scripts": {
        "serve": "vue-cli-service serve",
        "server:dev": "export NODE_ENV=development WEBPACK_TARGET=node SSR_ENV=dev && node --inspect server/bin/www",
        "dev": "concurrently \"npm run serve\" \"npm run server:dev\" "
    }
}

serve命令是採用客戶端模式啟動前端服務,webpack會在開發環境打包出客戶端Bundle,並存放在記憶體中;

server:dev命令通過設定環境變數【NODE_ENV】與【WEBPACK_TARGET】以獲取開發環境中伺服器端Bundle打包的webpack配置,通過設定環境變數【SSR_ENV】以使node應用程式識別當前環境為開發環境;

dev命令則是開發環境的執行命令,通過concurrently命令雙程式執行serve命令和server:dev命令。

接下來,我們來看一下開發環境的伺服器端渲染服務程式碼:

const webpack = require('webpack');
const axios = require('axios');
const MemoryFS = require('memory-fs');
const fs = require('fs');
const path = require('path');
const Router = require('koa-router');
const router = new Router();
// webpack配置檔案
const webpackConf = require('@vue/cli-service/webpack.config');
const { createBundleRenderer } = require("vue-server-renderer");
const serverCompiler = webpack(webpackConf);
const mfs = new MemoryFS();
serverCompiler.outputFileSystem = mfs;
// 監聽檔案修改,實時編譯獲取最新的 vue-ssr-server-bundle.json
let bundle;
serverCompiler.watch({}, (err, stats) => {
    if (err) {
        throw err;
    }
    stats = stats.toJson();
    stats.errors.forEach(error => console.error(error));
    stats.warnings.forEach(warn => console.warn(warn));
    const bundlePath = path.join(
        webpackConf.output.path,
        'vue-ssr-server-bundle.json',
    );
    bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'));
    console.log('New bundle generated.');
})
const handleRequest = async ctx => {
    if (!bundle) {
        ctx.body = '等待webpack打包完成後再訪問';
        return;
    }
    // 獲取最新的 vue-ssr-client-manifest.json
    const clientManifestResp = await axios.get(`http://localhost:8080/vue-ssr-client-manifest.json`);
    const clientManifest = clientManifestResp.data;
    const renderer = createBundleRenderer(bundle, {
        runInNewContext: false,
        template: fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8'),
        clientManifest,
    });
    return renderer;
}
const renderToString = (context, renderer) => {
    return new Promise((resolve, reject) => {
        renderer.renderToString(context, (err, html) => {
            err ? reject(err) : resolve(html);
        });
    });
};
router.get('*', async (ctx) => {
    const renderer = await handleRequest(ctx);
    try {
        const html = await renderToString(ctx, renderer);
        console.log(html);
        ctx.body = html;
    } catch(e) {}
});
module.exports = router;

​ 從程式碼中可以看出,開發環境的node伺服器端渲染服務流程和生產環境的基本一致,區別在於客戶端、伺服器端Bundle的獲取方式不同。

​ 在生產環境中,node直接讀取本地打包好的靜態資源;

​ 而在開發環境中,首先利用axios傳送http請求,獲取到前端專案打包在記憶體中的客戶端Bundle。同時利用【 @vue/cli-service/webpack.config】包獲取到當前環境(NODE_ENV=development WEBPACK_TARGET=node SSR_ENV=dev)下的webpack配置,使用webpack包和該webpack配置直接在當前node程式中執行伺服器端,並從中獲取到伺服器端Bundle。

​ 後續的流程則與生產環境相同。

5、Node應用配置

​ 到此為止,我們已經配置了伺服器端渲染所需的基本檔案,當然還需要一個node應用來進行服務的啟動。

// app.js
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const koaStatic = require('koa-static');
const koaMount = require('koa-mount');
const favicon = require('koa-favicon');
const isDev = process.env.SSR_ENV === 'dev';
// routes
const ssr = isDev ? require('./dev.ssr') : require('./prod.ssr');
// Static File Server
const resolve = file => path.resolve(__dirname, file);
app.use(favicon(resolve('./favicon.ico')));
app.use(koaMount('/', koaStatic(resolve('../public'))));
app.use(ssr.routes(), ssr.allowedMethods());
module.exports = app;

​ 在node入口檔案中,根據環境變數【SSR_ENV】判斷當前環境為開發環境還是生產環境,並呼叫對應的伺服器端渲染檔案。

​ 需要注意的是,如果webpack中配置的publicPath為相對路徑的話,在客戶端向頁面注入相對路徑的靜態資源後,瀏覽器會基於當前域名/IP訪問靜態資源。如果伺服器沒有做過其他代理(除該node服務以外的代理),這些靜態資源的請求會直接傳到我們的node應用上,最便捷的方式是在node應用中搭建一個靜態資源伺服器,對專案打包後的靜態資源(js、css、png、jpg等)進行代理,在此使用的是【koa-mount】和【koa-static】中介軟體。同時,還可以使用【koa-favicon】中介軟體掛載favicon.ico圖示。

伺服器端資料預取

​ 伺服器端資料預取,是在伺服器端對Vue應用進行渲染的時候,將資料注入到Vue例項中的功能,在以下兩種情況下比較常用:

1.頁面初始化時的資料量較大,影響首屏載入速度

2.部分資料在瀏覽器端無法獲取到

​ 針對資料預取,官方vue-server-renderer包提供的方案主要分為兩個步驟:

1.伺服器端資料預取

​ 伺服器端資料預取,主要是針對客戶端資料讀取慢導致首屏載入卡頓的問題。是在伺服器端的Vue例項渲染完成後,將資料注入到Vue例項的store中,程式碼可回顧【Vue應用程式改造】一節,具體流程如下:

​ 1)將store改為工廠模式,這個已在上文中講過,不再贅述;

​ 2)在vue例項中註冊靜態方法asyncData,提供給伺服器端進行呼叫,該方法的作用即呼叫store中的action方法,調取介面獲得資料;

// vue元件檔案
export default Vue.extend({
      asyncData({store, route, options}) {
        return store.dispatch('fetchData', {
            options,
        });
    },
});

​ 3)在伺服器端入口【entry-server.js】中呼叫asyncData方法獲取資料,並將資料儲存到【window.__INITIAL STATE\_】中,該配置在上文的【entry-server.js】檔案配置中可見;

​ 4)在客戶端入口【entry-client.js】中將【window.__INITIAL STATE\_】中的資料重新掛載到store中。

// entry-client.js
const {app, router, store} = createApp();
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__);
}
router.onReady(() => {
    app.$mount('#app');
});

2.客戶端資料預取

​ 客戶端資料預取,其實是作為伺服器端資料預取的補充。針對場景是在伺服器端將渲染完成的頁面交付給瀏覽器端後,路由切換等工作也隨之由瀏覽器端的vue虛擬路由接管,而不會再向伺服器端傳送頁面請求,導致切換到新的頁面後並不會觸發伺服器端資料預取的問題。

​ 針對這個問題,客戶端資料預取的策略是在客戶端入口【entry-client.js】中進行操作,當檢測到路由切換時優先進行資料調取(實際上這裡是在客戶端中複製伺服器端資料預取的操作流程),在資料載入完成後再進行vue應用的掛載。

​ 具體我們需要對【entry-client.js】進行改造:

// entry-client.js
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));
        });
        if (!activated.length) {
          return next();
        }
        Promise.all(activated.map(c => {
            if (c.extendOptions.asyncData) {
                return c.extendOptions.asyncData({
                    store,
                      route: to,
                      options: {},
                });
            }
        })).then(() => {
              next();
        }).catch(next);
    })
    app.$mount('#app');
});

注意事項

一、頁面載入異常問題

​ 由於伺服器端渲染後的html字串傳送到瀏覽器端之後,客戶端需要對其模板進行匹配,如果匹配不成功則無法正常渲染頁面,因此在一些情況下,會產生頁面載入異常的問題,主要有以下幾類。

1.模板頁中缺少客戶端或伺服器端可識別的渲染標識

​ 該問題會影響客戶端的靜態資源注入或伺服器端對Vue例項的渲染工作。對於客戶端來說,一般需要可識別的h5標籤元素進行掛載,本文中是採用一個id為app的div標籤;而對於伺服器端來說,需要一個官方vue-server-renderer包可識別的註釋標識,即。完整的模板頁程式碼如下:

// index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>模板頁</title>
  </head>
  <body>
    <div id="app"><!--vue-ssr-outlet--></div>
  </body>
</html>

2.客戶端與伺服器端路由不同

​ 在使用者向伺服器端傳送/a路由頁面請求,而伺服器端將/b路由對應的元件渲染成html字串並返回給瀏覽器端時,便會出現路由不匹配的問題。客戶端在瀏覽器端檢測出渲染後的Vue路由與當前瀏覽器中的路由不一致,會重新將頁面切換為/a路由下的頁面,導致頁面二次重新整理。

3.頁面靜態資源載入失敗

​ 由於在頁面靜態資源使用相對路徑時,瀏覽器會基於當前域名/IP進行靜態資源的請求,因此會向我們的Node服務進行靜態資源的請求。如果我們只做了伺服器端渲染服務,而沒有搭建靜態資源伺服器等對靜態資源進行代理,則會出現靜態資源載入失敗的問題。

4.H5標籤書寫不完整

​ 伺服器端在進行頁面渲染時,會對H5標籤進行自動補全,如

標籤會自動補全未寫的 或

二、多頁面專案打包

​ vue-server-renderer包中client-plugin和server-plugin外掛與SPA頁面的關係是一對一的,即一個SPA頁面對應一套客戶端Bundle和伺服器端Bundle,也就是一個客戶端json檔案和一個伺服器端json檔案對應一個SPA應用。如果我們在專案中建立了多個SPA頁面,則在打包時,client-plugin和server-plugin外掛會報錯提示有多個入口entry,無法正常匹配。

​ 但很多情況我們需要在一個專案中擁有多個SPA頁面,對於這個問題,我們可以使用shell指令碼呼叫npm命令使用webpack進行多次打包,而在webpack中根據命令引數進行動態的SPA頁面入口entry匹配。實際上,我們可以把這種做法理解為,將一個多SPA專案拆解成多個單SPA專案。

三、asyncData需返回Promise物件

​ 由於asyncData函式中進行資料預取和store初始化工作,是一個非同步操作,而伺服器端渲染需要在資料預取完成後將渲染好的頁面返回給瀏覽器。因此需要將asyncData的返回值設定為Promise物件,同樣,vuex中的action物件也需要返回一個Promise物件。

四、伺服器端對Vue鉤子的呼叫情況

​ 伺服器端在Vue例項元件渲染時,僅會觸發beforeCreate、created兩個鉤子。因此需要注意以下幾點問題:

​ 1.頁面初始化的內容儘量放在beforeCreate、created鉤子中;

​ 2.會佔用全域性記憶體的邏輯,如定時器、全域性變數、閉包等,儘量不要放在beforeCreate、created鉤子中,否則在beforeDestory方法中將無法登出,導致記憶體洩漏。

五、伺服器端渲染模板頁和SPA應用模板使用同一個html頁面

​ 有時,我們為了方便、易於管理以及專案簡潔,想直接將SPA應用的模板頁作為伺服器端渲染時的模板頁。這時需要注意一個問題,就是伺服器端渲染的模板頁比SPA應用模板頁多一個註釋標識,而在webpack打包時,會將SPA應用的模板中的註釋刪除掉。

​ 對於這個問題,可以在webpack配置中設定不對SPA應用模板頁進行打包,具體設定如下:

// vue.config.js
module.exports = {
    chainWebpack: (config) => {
        config
            .plugin('html-index')
            .tap(options => {
                options[0].minify = false;
                return options;
            });
    },
};

​ vue-cli3會對每個SPA頁面註冊一個html外掛進行webpack配置的管理。需要注意的是,當專案為單entry時,該外掛的名稱為’html’;而專案為多entry(即配置了pages屬性,即使pages中只有一個entry也會被識別為“多entry專案”)時,該外掛名稱為`html-${entryName}`,其中entryName為入口entry名。

六、客戶端與伺服器端公用的js包需要同時支援瀏覽器端和node端

​ 當客戶端、伺服器端共用js包時,主要是在資料預取的場景下,須使用具有“同構”策略的包,如使用axios代替vue-resource等。

回顧

在開頭我們對一些伺服器端渲染的問題進行過思考,並在文中做出瞭解答,在這裡重新一一回顧下。

1.Vue在頁面渲染時以Vue例項為基本單元,在伺服器端進行渲染時,是否也應對Vue例項進行渲染?

​ 官方【vue-server-renderer】包提供的方式就是對Vue例項進行渲染,並提供了Renderer、BundleRenderer兩個物件,分別是對“單一Vue例項”、“Vue專案中的Vue例項”進行渲染。常用的方式是後者,會在伺服器端根據使用者請求的路由,動態匹配需要渲染的Vue例項。

2.使用者與客戶端的關係是一對一,而與伺服器端的關係是多對一,如何避免多個使用者之間在伺服器端的資料共享的問題?

​ Vue伺服器端渲染採用客戶端、伺服器端協作渲染的方案。

​ 客戶端負責靜態資源的載入,採用的是單例模式;

​ 而伺服器端負責Vue例項的渲染工作,採用的是工廠模式,即所有可能產生“閉包”或“全域性變數”的地方,都需要改造成工廠模式,包括但不僅限於建立Vue例項、Vuex例項(store)、store中的module模組、vue-router例項、其他公用js配置檔案等。

3.如何實現同構策略?即讓伺服器端能夠執行前端的程式碼?

​ 首先,通過webpack進行打包,根據客戶端、伺服器端環境變數的不同,分別將專案打包為瀏覽器端可識別的模式,和Node端可識別的commonjs2模式;

​ 其次,對一些公用js包,採用相容瀏覽器、Node端的的包進行開發,如介面請求可採用axios.js進行處理。

4.伺服器端渲染的Vue專案,開發環境和生產環境分別應該如何部署?有何區別?

共同點:

​ 無論哪種環境下,該伺服器端渲染方案均需使用客戶端、伺服器端兩個Bundle共同渲染,因此需要對專案進行兩次打包。其中,客戶端Bundle包括前端專案原本打包出的瀏覽器可識別的靜態檔案,和客戶端Bundle入口檔案;伺服器端Bundle則是將專案打包為commonjs2模式並使用source-map方式注入到json檔案中。

不同點:

​ 首先,生產環境的部署相對簡單粗暴,即將打包後的客戶端、伺服器端Bundle放置到伺服器上,使用一個node服務進行執行;

​ 但開發環境的部署方式,則由於webpack打包執行後的客戶端儲存於記憶體中,而變得相對複雜一些。本文中使用的方案是通過http請求去讀取客戶端Bundle,而在Node中直接使用webpack包打包、讀取和監聽伺服器Bundle。

5.如何保證伺服器端渲染改造後的程式碼仍能通過訪問靜態資源的方式直接訪問到?

​ 針對這個問題,一種方案是在Node服務中對所有靜態資源請求進行代理,通過http轉發的方式將靜態資源轉發回瀏覽器端;另一種則是本文中使用的相對簡單快捷的方式,在Node服務中搭建靜態資源伺服器,將所有靜態資源掛載到特定路由下。

作者:安鳳翔

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559758/viewspace-2667273/,如需轉載,請註明出處,否則將追究法律責任。

下一篇: 沒有了~
vue-ssr在專案中的實踐
請登入後發表評論 登入
全部評論

相關文章