由于前端脚手架、打包工具、Node等版本的多样性,本文无法同时兼顾,文中所述皆基于以下技术栈进行。
脚手架:vue-cli3
打包工具:webpack4,集成在vue-cli3中,通过修改vue.config.js的方式进行配置
Node框架:koa2
服务器端渲染,即采用“同构”的策略,在服务器端对一部分前端代码进行渲染,减少浏览器对页面的渲染量。
通常服务器端渲染的优点和用途有以下几点:
1.更好的SEO
2.更快的页面加载速度
3.在服务器端完成数据的加载
但需要注意,在服务器端渲染提高客户端性能的同时,也带来了更高的服务器负荷的问题。在项目开发时需要权衡其优点及缺点。
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: '',
});
// 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向页面注入静态资源,并与服务器端渲染好的页面进行匹配。
需要注意的是,客户端与服务器端渲染的内容需要匹配才能进行正常的页面加载,一些页面加载异常问题将在下文进行具体描述。
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中。对于数据预取的操作和处理,我们将在下文【服务器端数据预取】一节进行具体介绍。
由于服务器端渲染服务需要客户端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脚本中梳理打包流程。
首先将【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": {}
}
}
由于需要进行两次打包,在打包客户端的时候会将之前的dist文件夹删除,为避免服务器端Bundle丢失,需将其临时移出dist文件夹。
在打包客户端时,将【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
]
}
}
经过以上几步打包流程,我们已经将项目打包为【vue-ssr-server-bundle.json】、【vue-ssr-client-manifest.json】、【前端静态资源】三个部分,之后我们需要在Node端利用打包后的这三个模块内容进行服务器端渲染工作。
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进行渲染。
在了解到区别后,我们将在这里采用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中,返回到浏览器端。
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。
后续的流程则与生产环境相同。
到此为止,我们已经配置了服务器端渲染所需的基本文件,当然还需要一个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包提供的方案主要分为两个步骤:
服务器端数据预取,主要是针对客户端数据读取慢导致首屏加载卡顿的问题。是在服务器端的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');
});
客户端数据预取,其实是作为服务器端数据预取的补充。针对场景是在服务器端将渲染完成的页面交付给浏览器端后,路由切换等工作也随之由浏览器端的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
<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">div>
body>
html>
2.客户端与服务器端路由不同
在用户向服务器端发送/a路由页面请求,而服务器端将/b路由对应的组件渲染成html字符串并返回给浏览器端时,便会出现路由不匹配的问题。客户端在浏览器端检测出渲染后的Vue路由与当前浏览器中的路由不一致,会重新将页面切换为/a路由下的页面,导致页面二次刷新。
3.页面静态资源加载失败
由于在页面静态资源使用相对路径时,浏览器会基于当前域名/IP进行静态资源的请求,因此会向我们的Node服务进行静态资源的请求。如果我们只做了服务器端渲染服务,而没有搭建静态资源服务器等对静态资源进行代理,则会出现静态资源加载失败的问题。
4.H5标签书写不完整
服务器端在进行页面渲染时,会对H5标签进行自动补全,如
标签会自动补全未写的或标签。因此,在某些情况下,服务器端渲染的H5页面和客户端的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函数中进行数据预取和store初始化工作,是一个异步操作,而服务器端渲染需要在数据预取完成后将渲染好的页面返回给浏览器。因此需要将asyncData的返回值设置为Promise对象,同样,vuex中的action对象也需要返回一个Promise对象。
服务器端在Vue实例组件渲染时,仅会触发beforeCreate、created两个钩子。因此需要注意以下几点问题:
1.页面初始化的内容尽量放在beforeCreate、created钩子中;
2.会占用全局内存的逻辑,如定时器、全局变量、闭包等,尽量不要放在beforeCreate、created钩子中,否则在beforeDestory方法中将无法注销,导致内存泄漏。
有时,我们为了方便、易于管理以及项目简洁,想直接将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包时,主要是在数据预取的场景下,须使用具有“同构”策略的包,如使用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服务中搭建静态资源服务器,将所有静态资源挂载到特定路由下。
作者:安凤翔