您好,登錄后才能下訂單哦!
本篇內(nèi)容介紹了“Vue項目中如何實現(xiàn)服務(wù)器端渲染”的有關(guān)知識,在實際案例的操作過程中,不少人都會遇到這樣的困境,接下來就讓小編帶領(lǐng)大家學習一下如何處理這些情況吧!希望大家仔細閱讀,能夠?qū)W有所成!
由于前端腳手架、打包工具、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)點及缺點。
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)資源的方式直接訪問到?
對于這些思考,將在文末進行回顧。
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+版本
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)流程如下圖所示分為【模板頁】(HTML)、【客戶端】(Client Bundle)、【服務(wù)器端】(Server Bundle)三個模塊。三個模塊功能如下:
模板頁:提供給客戶端和服務(wù)器端渲染的html框架,令客戶端和服務(wù)器端在該框架中進行頁面的渲染
客戶端:僅在瀏覽器端執(zhí)行,向模板頁中注入js、css等靜態(tài)資源
服務(wù)器端:僅在服務(wù)器端執(zhí)行,將Vue實例渲染為html字符串,注入到模板頁的對應(yīng)位置中
整個服務(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)容需要匹配才能進行正常的頁面加載,一些頁面加載異常問題將在下文進行具體描述。
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é)進行具體介紹。
由于服務(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腳本中梳理打包流程。
首先將【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": {} } }
由于需要進行兩次打包,在打包客戶端的時候會將之前的dist文件夾刪除,為避免服務(wù)器端Bundle丟失,需將其臨時移出dist文件夾。
在打包客戶端時,將【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 ] } }
經(jīng)過以上幾步打包流程,我們已經(jīng)將項目打包為【vue-ssr-server-bundle.json】、【vue-ssr-client-manifest.json】、【前端靜態(tài)資源】三個部分,之后我們需要在Node端利用打包后的這三個模塊內(nèi)容進行服務(wù)器端渲染工作。
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進行渲染。
在了解到區(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中,返回到瀏覽器端。
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)境相同。
到此為止,我們已經(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ù)器端對Vue應(yīng)用進行渲染的時候,將數(shù)據(jù)注入到Vue實例中的功能,在以下兩種情況下比較常用:
1.頁面初始化時的數(shù)據(jù)量較大,影響首屏加載速度
2.部分數(shù)據(jù)在瀏覽器端無法獲取到
針對數(shù)據(jù)預(yù)取,官方vue-server-renderer包提供的方案主要分為兩個步驟:
服務(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'); });
客戶端數(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函數(shù)中進行數(shù)據(jù)預(yù)取和store初始化工作,是一個異步操作,而服務(wù)器端渲染需要在數(shù)據(jù)預(yù)取完成后將渲染好的頁面返回給瀏覽器。因此需要將asyncData的返回值設(shè)置為Promise對象,同樣,vuex中的action對象也需要返回一個Promise對象。
服務(wù)器端在Vue實例組件渲染時,僅會觸發(fā)beforeCreate、created兩個鉤子。因此需要注意以下幾點問題:
1.頁面初始化的內(nèi)容盡量放在beforeCreate、created鉤子中;
2.會占用全局內(nèi)存的邏輯,如定時器、全局變量、閉包等,盡量不要放在beforeCreate、created鉤子中,否則在beforeDestory方法中將無法注銷,導致內(nèi)存泄漏。
有時,我們?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包時,主要是在數(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ì)量的實用文章!
免責聲明:本站發(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)容。