溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

Vue項目中如何實現(xiàn)服務(wù)器端渲染

發(fā)布時間:2021-12-10 16:02:34 來源:億速云 閱讀:186 作者:iii 欄目:開發(fā)技術(shù)

本篇內(nèi)容介紹了“Vue項目中如何實現(xiàn)服務(wù)器端渲染”的有關(guān)知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領(lǐng)大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠?qū)W有所成!

vue-ssr在項目中的實踐

寫在文前

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

腳手架:vue-cli3

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

Node框架:koa2

簡介

服務(wù)器端渲染,即采用“同構(gòu)”的策略,在服務(wù)器端對一部分前端代碼進行渲染,減少瀏覽器對頁面的渲染量。

通常服務(wù)器端渲染的優(yōu)點和用途有以下幾點:

1.更好的SEO

2.更快的頁面加載速度

3.在服務(wù)器端完成數(shù)據(jù)的加載

但需要注意,在服務(wù)器端渲染提高客戶端性能的同時,也帶來了更高的服務(wù)器負荷的問題。在項目開發(fā)時需要權(quán)衡其優(yōu)點及缺點。

Vue項目中如何實現(xiàn)服務(wù)器端渲染?

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

1.Vue在頁面渲染時以Vue實例為基本單元,在服務(wù)器端進行渲染時,是否也應(yīng)對Vue實例進行渲染?

2.用戶與客戶端的關(guān)系是一對一,而與服務(wù)器端的關(guān)系是多對一,如何避免多個用戶之間在服務(wù)器端的數(shù)據(jù)共享的問題?

3.如何實現(xiàn)同構(gòu)策略?即讓服務(wù)器端能夠運行前端的代碼?

4.服務(wù)器端渲染的Vue項目,開發(fā)環(huán)境和生產(chǎn)環(huán)境分別應(yīng)該如何部署?有何區(qū)別?

5.如何保證服務(wù)器端渲染改造后的代碼仍能通過訪問靜態(tài)資源的方式直接訪問到?

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

具體實現(xiàn)方案

Vue官方提供了【vue-server-renderer】包實現(xiàn)Vue項目的服務(wù)器渲染,安裝方式如下:

npm install vue-server-renderer --save

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

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

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

一、最簡單的實現(xiàn)

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

// 1.創(chuàng)建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);
// 通過訂閱事件,在回調(diào)中進行操作
// event可取值'data'、'beforeStart'、'start'、'beforeEnd'、'end'、'error'等
renderStream.on(event, (res) => {});

但通常情況下,我們沒有必要在服務(wù)器端創(chuàng)建Vue實例并進行渲染,而是需要對前端的Vue項目中每個SPA的Vue實例進行渲染,基于此,vue-server-renderer為我們提供了一套如下的服務(wù)器端渲染方案。

二、完整的實現(xiàn)

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

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

客戶端:僅在瀏覽器端執(zhí)行,向模板頁中注入js、css等靜態(tài)資源

服務(wù)器端:僅在服務(wù)器端執(zhí)行,將Vue實例渲染為html字符串,注入到模板頁的對應(yīng)位置中

Vue項目中如何實現(xiàn)服務(wù)器端渲染

整個服務(wù)的構(gòu)建流程分為以下幾步:

1.通過webpack將Vue應(yīng)用打包為瀏覽器端可執(zhí)行的客戶端Bundle;

2.通過webpack將Vue應(yīng)用打包為Node端可執(zhí)行的服務(wù)器端Bundle;

3.Node端調(diào)用服務(wù)器端Bundle渲染Vue應(yīng)用,并將渲染好的html字符串以及客戶端Bundle發(fā)送至瀏覽器;

4.瀏覽器端接收到后,調(diào)用客戶端Bundle向頁面注入靜態(tài)資源,并與服務(wù)器端渲染好的頁面進行匹配。

需要注意的是,客戶端與服務(wù)器端渲染的內(nèi)容需要匹配才能進行正常的頁面加載,一些頁面加載異常問題將在下文進行具體描述。

三、具體代碼實現(xiàn)
1、Vue應(yīng)用程序改造

SPA模式下,用戶與Vue應(yīng)用是一對一的關(guān)系,而在SSR模式下,由于Vue實例是在服務(wù)器端進行渲染,而服務(wù)器是所有用戶共用的,用戶與Vue應(yīng)用的關(guān)系變?yōu)榱硕鄬σ弧_@就導致多個用戶共用同一個Vue實例,導致實例中的數(shù)據(jù)相互污染。

針對這個問題,我們需要對Vue應(yīng)用的入口進行改造,將Vue實例的創(chuàng)建改為“工廠模式”,在每次渲染的時候創(chuàng)建新的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實例內(nèi)部使用的模塊也配置為“工廠模式”,避免路由、狀態(tài)等在多個Vue實例間共用。

同時,由于我們在SSR過程中需要使用到客戶端和服務(wù)器端兩個模塊,因此需要配置客戶端、服務(wù)器端兩個入口。

客戶端入口配置如下:

// 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的功能是在瀏覽器端接收到服務(wù)器渲染好的html字符串后,向頁面中注入靜態(tài)資源以及頁面的二次渲染工作,因此我們在Vue應(yīng)用的客戶端入口中,只需像之前一樣將Vue實例掛載到指定的html標簽上即可。

同時,服務(wù)器端在渲染時如果有數(shù)據(jù)預(yù)取操作,會將store中的數(shù)據(jù)先注入到【window.__INITIALSTATE\_】,在客戶端中,我們需要將window.__INITIALSTATE\_中的值重新賦給store。

服務(wù)器端入口配置如下:

// entry-server.js
import {createApp} from './main';
export default (context) => {
    return new Promise((resolve, reject) => {
        const {app, router, store} = createApp();
        // 設(shè)置服務(wù)器端 router 的位置
        router.push(context.url);
        // 等到 router 將可能的異步組件和鉤子函數(shù)解析完
        router.onReady(() => {
            const matchedComponents = router.getMatchedComponents();
            // 匹配不到的路由,執(zhí)行 reject 函數(shù),并返回 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(() => {
                // 狀態(tài)將自動序列化為 window.__INITIAL_STATE__,并注入 HTML。
                context.state = store.state;
                resolve(app);
            }).catch(reject);
        }, reject);
    });
};

服務(wù)器端需要根據(jù)用戶的請求,動態(tài)匹配需要渲染的Vue組件,并設(shè)置router和store等模塊。

對于router,只需調(diào)用vue-router的push方法進行路由切換即可;

對于store,則需要檢測并調(diào)用Vue組件中的【asyncData】方法進行store的初始化,并將初始化后的state賦值給上下文,服務(wù)器在進行渲染時會將上下文中的state序列化為window.__INITIALSTATE\_,并注入到html中。對于數(shù)據(jù)預(yù)取的操作和處理,我們將在下文【服務(wù)器端數(shù)據(jù)預(yù)取】一節(jié)進行具體介紹。

2、Webpack打包邏輯配置

由于服務(wù)器端渲染服務(wù)需要客戶端Bundle和服務(wù)器端Bundle兩個包,因此需要利用webpack進行兩次打包,分別打包客戶端和服務(wù)器端。這里我們可以通過shell腳本進行打包邏輯的編寫:

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

在shell命令中,我們配置了【W(wǎng)EBPACK_TARGET】這一環(huán)境變量,為webpack提供可辨別客戶端/服務(wù)器端打包流程的標識。

同時,vue-server-renderer為我們提供了【server-plugin】和【client-plugin】兩個webpack插件,用于分別打包服務(wù)器端和客戶端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 = {
      /**
       * 靜態(tài)資源在請求時,如果請求路徑為相對路徑,則會基于當前域名進行訪問
     * 在本地開發(fā)時,為保證靜態(tài)資源的正常加載,在8080端口啟動一個靜態(tài)資源服務(wù)器
     * 該處理將會在第四小節(jié)《Node端開發(fā)環(huán)境配置》中進行詳細介紹
     */
    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) => {
          // 關(guān)閉vue-loader中默認的服務(wù)器端渲染函數(shù)
        config.module
            .rule('vue')
            .use('vue-loader')
            .tap((options) => {
                merge(options, {
                    optimizeSSR: false,
                });
            });
    },
    configureWebpack: {
          // 需要開啟source-map文件映射,因為服務(wù)器端在渲染時,
          // 會通過Bundle中的map文件映射關(guān)系進行文件的查詢
        devtool: 'source-map',
          // 服務(wù)器端在Node環(huán)境中運行,需要打包為類Node.js環(huán)境可用包(使用Node.js require加載chunk)
          // 客戶端在瀏覽器中運行,需要打包為類瀏覽器環(huán)境里可用包
        target: TARGET_NODE ? 'node' : 'web',
          // 關(guān)閉對node變量、模塊的polyfill
        node: TARGET_NODE ? undefined : false,
        output: {
              // 配置模塊的暴露方式,服務(wù)器端采用module.exports的方式,客戶端采用默認的var變量方式
            libraryTarget: TARGET_NODE ? 'commonjs2' : undefined,
        },
          // 外置化應(yīng)用程序依賴模塊??梢允狗?wù)器構(gòu)建速度更快
        externals: TARGET_NODE ? nodeExternals({
            whitelist: [/\.css$/],
        }) : undefined,
        plugins: [
              // 根據(jù)之前配置的環(huán)境變量判斷打包為客戶端/服務(wù)器端Bundle
            TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin(),
        ],
    },
};

結(jié)合webpack配置文件的代碼和注釋,我們再回到打包的shell腳本中梳理打包流程。

1)打包服務(wù)器端Bundle

首先將【W(wǎng)EBPACK_TARGET】環(huán)境變量設(shè)置為node,webpack會將入口entry設(shè)置為服務(wù)器端入口【entry-server.js】,通過插件【server-plugin】進行打包。

打包后會在dist文件夾下生成【vue-ssr-server-bundle.json】文件(該名稱為默認名稱,可在插件中進行設(shè)置),該文件有三個屬性entry、files、maps。其中entry屬性是打包后的入口文件路徑字符串,files屬性是一組打包后的【文件路徑-文件內(nèi)容 鍵值對】,編譯過的文件的內(nèi)容都會被存到該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)將服務(wù)器端打包后的文件臨時移出dist文件夾

由于需要進行兩次打包,在打包客戶端的時候會將之前的dist文件夾刪除,為避免服務(wù)器端Bundle丟失,需將其臨時移出dist文件夾。

3)打包客戶端Bundle

在打包客戶端時,將【W(wǎng)EBPACK_TARGET】環(huán)境變量修改為web,webpack會將入口entry設(shè)置為客戶端入口【entry-client.js】,通過插件【client-plugin】進行打包。

打包后會在dist文件夾下生成前端項目打包后的靜態(tài)資源文件,以及【vue-ssr-client-manifest.json】文件,其中靜態(tài)資源文件可部署至服務(wù)器提供傳統(tǒng)SPA服務(wù)。而vue-ssr-client-manifest.json文件中包含publicPath、all、initial、async、modules屬性,其作用分別如下:

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

all:打包后的所有靜態(tài)資源文件路徑

initial:頁面初始化時需要加載的文件,會在頁面加載時配置到preload中

async:頁面跳轉(zhuǎn)時需要加載的文件,會在頁面加載時配置到prefetch中

modules:項目的各個模塊包含的文件的序號,對應(yīng)all中文件的順序

// vue-ssr-client-manifest.json
{
      "publicPath": "/",
      "all": [],
      "initial": [],
      "async": [],
      "modules": {
          "moduleId": [
              fileIndex
        ]
    }
}

4)將臨時移出dist的服務(wù)器端Bundle移回dist文件夾

3、Node端生產(chǎn)環(huán)境配置

經(jīng)過以上幾步打包流程,我們已經(jīng)將項目打包為【vue-ssr-server-bundle.json】、【vue-ssr-client-manifest.json】、【前端靜態(tài)資源】三個部分,之后我們需要在Node端利用打包后的這三個模塊內(nèi)容進行服務(wù)器端渲染工作。

1)Renderer、BundleRenderer的區(qū)別

vue-server-renderer中存在兩個用于服務(wù)器端渲染的主要類【Renderer】、【BundleRenderer】。

在【最簡單的實現(xiàn)】一節(jié)我們提到過【createRenderer】方法,實際上就是創(chuàng)建Renderer對象進行渲染工作,該對象包含renderToString和renderToStream兩個方法,用于將Vue實例渲染成html字符串或生成node可讀流。

而在【完整的實現(xiàn)】一節(jié)中,我們采用的是將項目打包為客戶端、服務(wù)器端Bundle的方法,此時需要利用vue-server-renderer的另一個方法【createBundleRenderer】,創(chuàng)建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方法不同,它接收的是服務(wù)器端Bundle文件或文件路徑。在執(zhí)行時會先判斷接收的是對象還是字符串,如果為字符串則將其作為文件路徑去讀取文件。在讀取到Bundle文件后會對【W(wǎng)ebpack打包邏輯配置】一節(jié)中所說的服務(wù)器端Bundle的相關(guān)屬性進行解析。同時構(gòu)建Renderer對象,調(diào)用Renderer對象的renderToString和renderToStream方法。

可以看出,BundleRenderer和Renderer的區(qū)別,僅在于多一步Bundle解析的過程,而后仍使用Renderer進行渲染。

2)代碼實現(xiàn)

在了解到區(qū)別后,我們將在這里采用BundleRenderer對象進行服務(wù)器端渲染,代碼如下:

// 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.獲取服務(wù)器端、客戶端、模板文件,通過createBundleRenderer方法構(gòu)建BundleRenderer對象;

2.接收到用戶請求,調(diào)用renderToString方法并傳入請求上下文,此時服務(wù)器端渲染服務(wù)會調(diào)用服務(wù)器端入口文件entry-server.js進行頁面渲染;

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

4、Node端開發(fā)環(huán)境配置

Vue官方只提供了針對Vue實例和打包后的Bundle包進行服務(wù)器端渲染的方案,但在開發(fā)環(huán)境中我們會面臨以下幾個問題:

1)webpack將打包后的資源文件存放在了內(nèi)存中,如何獲取到打包后的Bundle的json文件?

2)如何在開發(fā)環(huán)境中同時打包和運行客戶端與服務(wù)器端?

在此,我們采用的策略是使用webpack啟動開發(fā)環(huán)境的前端項目,通過http請求獲取到存在內(nèi)存中的客戶端靜態(tài)資源【vue-ssr-client-manifest.json】;同時在Node中,使用【 @vue/cli-service/webpack.config】獲取到服務(wù)器端的webpack配置,利用webpack包直接進行服務(wù)器端Bundle的打包操作,監(jiān)聽并獲取到最新的【vue-ssr-server-bundle.json】文件。這樣,我們就獲取到了客戶端與服務(wù)器端文件,之后的流程則與生產(chǎn)環(huán)境中相同。

首先,我們來看一下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命令是采用客戶端模式啟動前端服務(wù),webpack會在開發(fā)環(huán)境打包出客戶端Bundle,并存放在內(nèi)存中;

server:dev命令通過設(shè)置環(huán)境變量【NODE_ENV】與【W(wǎng)EBPACK_TARGET】以獲取開發(fā)環(huán)境中服務(wù)器端Bundle打包的webpack配置,通過設(shè)置環(huán)境變量【SSR_ENV】以使node應(yīng)用程序識別當前環(huán)境為開發(fā)環(huán)境;

dev命令則是開發(fā)環(huán)境的運行命令,通過concurrently命令雙進程執(zhí)行serve命令和server:dev命令。

接下來,我們來看一下開發(fā)環(huán)境的服務(wù)器端渲染服務(wù)代碼:

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;
// 監(jiān)聽文件修改,實時編譯獲取最新的 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;

從代碼中可以看出,開發(fā)環(huán)境的node服務(wù)器端渲染服務(wù)流程和生產(chǎn)環(huán)境的基本一致,區(qū)別在于客戶端、服務(wù)器端Bundle的獲取方式不同。

在生產(chǎn)環(huán)境中,node直接讀取本地打包好的靜態(tài)資源;

而在開發(fā)環(huán)境中,首先利用axios發(fā)送http請求,獲取到前端項目打包在內(nèi)存中的客戶端Bundle。同時利用【 @vue/cli-service/webpack.config】包獲取到當前環(huán)境(NODE_ENV=development WEBPACK_TARGET=node SSR_ENV=dev)下的webpack配置,使用webpack包和該webpack配置直接在當前node程序中運行服務(wù)器端,并從中獲取到服務(wù)器端Bundle。

后續(xù)的流程則與生產(chǎn)環(huán)境相同。

5、Node應(yīng)用配置

到此為止,我們已經(jīng)配置了服務(wù)器端渲染所需的基本文件,當然還需要一個node應(yīng)用來進行服務(wù)的啟動。

// 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入口文件中,根據(jù)環(huán)境變量【SSR_ENV】判斷當前環(huán)境為開發(fā)環(huán)境還是生產(chǎn)環(huán)境,并調(diào)用對應(yīng)的服務(wù)器端渲染文件。

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

服務(wù)器端數(shù)據(jù)預(yù)取

服務(wù)器端數(shù)據(jù)預(yù)取,是在服務(wù)器端對Vue應(yīng)用進行渲染的時候,將數(shù)據(jù)注入到Vue實例中的功能,在以下兩種情況下比較常用:

1.頁面初始化時的數(shù)據(jù)量較大,影響首屏加載速度

2.部分數(shù)據(jù)在瀏覽器端無法獲取到

針對數(shù)據(jù)預(yù)取,官方vue-server-renderer包提供的方案主要分為兩個步驟:

1.服務(wù)器端數(shù)據(jù)預(yù)取

服務(wù)器端數(shù)據(jù)預(yù)取,主要是針對客戶端數(shù)據(jù)讀取慢導致首屏加載卡頓的問題。是在服務(wù)器端的Vue實例渲染完成后,將數(shù)據(jù)注入到Vue實例的store中,代碼可回顧【Vue應(yīng)用程序改造】一節(jié),具體流程如下:

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

2)在vue實例中注冊靜態(tài)方法asyncData,提供給服務(wù)器端進行調(diào)用,該方法的作用即調(diào)用store中的action方法,調(diào)取接口獲得數(shù)據(jù);

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

3)在服務(wù)器端入口【entry-server.js】中調(diào)用asyncData方法獲取數(shù)據(jù),并將數(shù)據(jù)存儲到【window.__INITIALSTATE\_】中,該配置在上文的【entry-server.js】文件配置中可見;

4)在客戶端入口【entry-client.js】中將【window.__INITIALSTATE\_】中的數(shù)據(jù)重新掛載到store中。

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

2.客戶端數(shù)據(jù)預(yù)取

客戶端數(shù)據(jù)預(yù)取,其實是作為服務(wù)器端數(shù)據(jù)預(yù)取的補充。針對場景是在服務(wù)器端將渲染完成的頁面交付給瀏覽器端后,路由切換等工作也隨之由瀏覽器端的vue虛擬路由接管,而不會再向服務(wù)器端發(fā)送頁面請求,導致切換到新的頁面后并不會觸發(fā)服務(wù)器端數(shù)據(jù)預(yù)取的問題。

針對這個問題,客戶端數(shù)據(jù)預(yù)取的策略是在客戶端入口【entry-client.js】中進行操作,當檢測到路由切換時優(yōu)先進行數(shù)據(jù)調(diào)取(實際上這里是在客戶端中復制服務(wù)器端數(shù)據(jù)預(yù)取的操作流程),在數(shù)據(jù)加載完成后再進行vue應(yīng)用的掛載。

具體我們需要對【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);
        // 找出兩個匹配列表的差異組件,不做重復的數(shù)據(jù)讀取工作
        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');
});

注意事項

一、頁面加載異常問題

由于服務(wù)器端渲染后的html字符串發(fā)送到瀏覽器端之后,客戶端需要對其模板進行匹配,如果匹配不成功則無法正常渲染頁面,因此在一些情況下,會產(chǎn)生頁面加載異常的問題,主要有以下幾類。

1.模板頁中缺少客戶端或服務(wù)器端可識別的渲染標識

該問題會影響客戶端的靜態(tài)資源注入或服務(wù)器端對Vue實例的渲染工作。對于客戶端來說,一般需要可識別的h6標簽元素進行掛載,本文中是采用一個id為app的div標簽;而對于服務(wù)器端來說,需要一個官方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.客戶端與服務(wù)器端路由不同

在用戶向服務(wù)器端發(fā)送/a路由頁面請求,而服務(wù)器端將/b路由對應(yīng)的組件渲染成html字符串并返回給瀏覽器端時,便會出現(xiàn)路由不匹配的問題。客戶端在瀏覽器端檢測出渲染后的Vue路由與當前瀏覽器中的路由不一致,會重新將頁面切換為/a路由下的頁面,導致頁面二次刷新。

3.頁面靜態(tài)資源加載失敗

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

4.H5標簽書寫不完整

服務(wù)器端在進行頁面渲染時,會對H5標簽進行自動補全,如

標簽會自動補全未寫的或

二、多頁面項目打包

vue-server-renderer包中client-plugin和server-plugin插件與SPA頁面的關(guān)系是一對一的,即一個SPA頁面對應(yīng)一套客戶端Bundle和服務(wù)器端Bundle,也就是一個客戶端json文件和一個服務(wù)器端json文件對應(yīng)一個SPA應(yīng)用。如果我們在項目中創(chuàng)建了多個SPA頁面,則在打包時,client-plugin和server-plugin插件會報錯提示有多個入口entry,無法正常匹配。

但很多情況我們需要在一個項目中擁有多個SPA頁面,對于這個問題,我們可以使用shell腳本調(diào)用npm命令使用webpack進行多次打包,而在webpack中根據(jù)命令參數(shù)進行動態(tài)的SPA頁面入口entry匹配。實際上,我們可以把這種做法理解為,將一個多SPA項目拆解成多個單SPA項目。

三、asyncData需返回Promise對象

由于asyncData函數(shù)中進行數(shù)據(jù)預(yù)取和store初始化工作,是一個異步操作,而服務(wù)器端渲染需要在數(shù)據(jù)預(yù)取完成后將渲染好的頁面返回給瀏覽器。因此需要將asyncData的返回值設(shè)置為Promise對象,同樣,vuex中的action對象也需要返回一個Promise對象。

四、服務(wù)器端對Vue鉤子的調(diào)用情況

服務(wù)器端在Vue實例組件渲染時,僅會觸發(fā)beforeCreate、created兩個鉤子。因此需要注意以下幾點問題:

1.頁面初始化的內(nèi)容盡量放在beforeCreate、created鉤子中;

2.會占用全局內(nèi)存的邏輯,如定時器、全局變量、閉包等,盡量不要放在beforeCreate、created鉤子中,否則在beforeDestory方法中將無法注銷,導致內(nèi)存泄漏。

五、服務(wù)器端渲染模板頁和SPA應(yīng)用模板使用同一個html頁面

有時,我們?yōu)榱朔奖?、易于管理以及項目簡潔,想直接將SPA應(yīng)用的模板頁作為服務(wù)器端渲染時的模板頁。這時需要注意一個問題,就是服務(wù)器端渲染的模板頁比SPA應(yīng)用模板頁多一個注釋標識,而在webpack打包時,會將SPA應(yīng)用的模板中的注釋刪除掉。

對于這個問題,可以在webpack配置中設(shè)置不對SPA應(yīng)用模板頁進行打包,具體設(shè)置如下:

// 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名。

六、客戶端與服務(wù)器端公用的js包需要同時支持瀏覽器端和node端

當客戶端、服務(wù)器端共用js包時,主要是在數(shù)據(jù)預(yù)取的場景下,須使用具有“同構(gòu)”策略的包,如使用axios代替vue-resource等。

回顧

在開頭我們對一些服務(wù)器端渲染的問題進行過思考,并在文中做出了解答,在這里重新一一回顧下。

1.Vue在頁面渲染時以Vue實例為基本單元,在服務(wù)器端進行渲染時,是否也應(yīng)對Vue實例進行渲染?

官方【vue-server-renderer】包提供的方式就是對Vue實例進行渲染,并提供了Renderer、BundleRenderer兩個對象,分別是對“單一Vue實例”、“Vue項目中的Vue實例”進行渲染。常用的方式是后者,會在服務(wù)器端根據(jù)用戶請求的路由,動態(tài)匹配需要渲染的Vue實例。

2.用戶與客戶端的關(guān)系是一對一,而與服務(wù)器端的關(guān)系是多對一,如何避免多個用戶之間在服務(wù)器端的數(shù)據(jù)共享的問題?

Vue服務(wù)器端渲染采用客戶端、服務(wù)器端協(xié)作渲染的方案。

客戶端負責靜態(tài)資源的加載,采用的是單例模式;

而服務(wù)器端負責Vue實例的渲染工作,采用的是工廠模式,即所有可能產(chǎn)生“閉包”或“全局變量”的地方,都需要改造成工廠模式,包括但不僅限于創(chuàng)建Vue實例、Vuex實例(store)、store中的module模塊、vue-router實例、其他公用js配置文件等。

3.如何實現(xiàn)同構(gòu)策略?即讓服務(wù)器端能夠運行前端的代碼?

首先,通過webpack進行打包,根據(jù)客戶端、服務(wù)器端環(huán)境變量的不同,分別將項目打包為瀏覽器端可識別的模式,和Node端可識別的commonjs2模式;

其次,對一些公用js包,采用兼容瀏覽器、Node端的的包進行開發(fā),如接口請求可采用axios.js進行處理。

4.服務(wù)器端渲染的Vue項目,開發(fā)環(huán)境和生產(chǎn)環(huán)境分別應(yīng)該如何部署?有何區(qū)別?

共同點:

無論哪種環(huán)境下,該服務(wù)器端渲染方案均需使用客戶端、服務(wù)器端兩個Bundle共同渲染,因此需要對項目進行兩次打包。其中,客戶端Bundle包括前端項目原本打包出的瀏覽器可識別的靜態(tài)文件,和客戶端Bundle入口文件;服務(wù)器端Bundle則是將項目打包為commonjs2模式并使用source-map方式注入到j(luò)son文件中。

不同點:

首先,生產(chǎn)環(huán)境的部署相對簡單粗暴,即將打包后的客戶端、服務(wù)器端Bundle放置到服務(wù)器上,使用一個node服務(wù)進行運行;

但開發(fā)環(huán)境的部署方式,則由于webpack打包運行后的客戶端存儲于內(nèi)存中,而變得相對復雜一些。本文中使用的方案是通過http請求去讀取客戶端Bundle,而在Node中直接使用webpack包打包、讀取和監(jiān)聽服務(wù)器Bundle。

5.如何保證服務(wù)器端渲染改造后的代碼仍能通過訪問靜態(tài)資源的方式直接訪問到?

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

“Vue項目中如何實現(xiàn)服務(wù)器端渲染”的內(nèi)容就介紹到這里了,感謝大家的閱讀。如果想了解更多行業(yè)相關(guān)的知識可以關(guān)注億速云網(wǎng)站,小編將為大家輸出更多高質(zhì)量的實用文章!

向AI問一下細節(jié)

免責聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

vue
AI