Vue 服务端渲染实践 ——Web应用首屏耗时最优化方案
随着各大前端框架的诞生和演变,[代码]SPA[代码]开始流行,单页面应用的优势在于可以不重新加载整个页面的情况下,通过[代码]ajax[代码]和服务器通信,实现整个[代码]Web[代码]应用拒不更新,带来了极致的用户体验。然而,对于需要[代码]SEO[代码]、追求极致的首屏性能的应用,前端渲染的[代码]SPA[代码]是糟糕的。好在[代码]Vue 2.0[代码]后是支持服务端渲染的,零零散散花费了两三周事件,通过改造现有项目,基本完成了在现有项目中实践了[代码]Vue[代码]服务端渲染。
关于Vue服务端渲染的原理、搭建,官方文档已经讲的比较详细了,因此,本文不是抄袭文档,而是文档的补充。特别是对于如何与现有项目进行很好的结合,还是需要费很大功夫的。本文主要对我所在的项目中进行[代码]Vue[代码]服务端渲染的改造过程进行阐述,加上一些个人的理解,作为分享与学习。
概述
本文主要分以下几个方面:
什么是服务端渲染?服务端渲染的原理是什么?
如何在基于[代码]Koa[代码]的[代码]Web Server Frame[代码]上配置服务端渲染?
基本用法
[代码]Webpack[代码]配置
开发环境搭建
渲染中间件配置
如何对现有项目进行改造?
基本目录改造;
在服务端用[代码]vue-router[代码]分割代码;
在服务端预拉取数据;
客户端托管全局状态;
常见问题的解决方案;
什么是服务端渲染?服务端渲染的原理是什么?
[代码]Vue.js[代码]是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出[代码]Vue[代码]组件,进行生成[代码]DOM[代码]和操作[代码]DOM[代码]。然而,也可以将同一个组件渲染为服务器端的[代码]HTML[代码]字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。
上面这段话是源自Vue服务端渲染文档的解释,用通俗的话来说,大概可以这么理解:
服务端渲染的目的是:性能优势。 在服务端生成对应的[代码]HTML[代码]字符串,客户端接收到对应的[代码]HTML[代码]字符串,能立即渲染[代码]DOM[代码],最高效的首屏耗时。此外,由于服务端直接生成了对应的[代码]HTML[代码]字符串,对[代码]SEO[代码]也非常友好;
服务端渲染的本质是:生成应用程序的“快照”。将[代码]Vue[代码]及对应库运行在服务端,此时,[代码]Web Server Frame[代码]实际上是作为代理服务器去访问接口服务器来预拉取数据,从而将拉取到的数据作为[代码]Vue[代码]组件的初始状态。
服务端渲染的原理是:虚拟[代码]DOM[代码]。在[代码]Web Server Frame[代码]作为代理服务器去访问接口服务器来预拉取数据后,这是服务端初始化组件需要用到的数据,此后,组件的[代码]beforeCreate[代码]和[代码]created[代码]生命周期会在服务端调用,初始化对应的组件后,[代码]Vue[代码]启用虚拟[代码]DOM[代码]形成初始化的[代码]HTML[代码]字符串。之后,交由客户端托管。实现前后端同构应用。
如何在基于[代码]Koa[代码]的[代码]Web Server Frame[代码]上配置服务端渲染?
基本用法
需要用到[代码]Vue[代码]服务端渲染对应库[代码]vue-server-renderer[代码],通过[代码]npm[代码]安装:
[代码]npm install vue vue-server-renderer --save
[代码]
最简单的,首先渲染一个[代码]Vue[代码]实例:
[代码]
// 第 1 步:创建一个 Vue 实例
const Vue = require('vue');
const app = new Vue({
template: `<div>Hello World</div>`
});
// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer();
// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
if (err) {
throw err;
}
console.log(html);
// => <div data-server-rendered="true">Hello World</div>
});
[代码]
与服务器集成:
[代码]
module.exports = async function(ctx) {
ctx.status = 200;
let html = '';
try {
// ...
html = await renderer.renderToString(app, ctx);
} catch (err) {
ctx.logger('Vue SSR Render error', JSON.stringify(err));
html = await ctx.getErrorPage(err); // 渲染出错的页面
}
ctx.body = html;
}
[代码]
使用页面模板:
当你在渲染[代码]Vue[代码]应用程序时,[代码]renderer[代码]只从应用程序生成[代码]HTML[代码]标记。在这个示例中,我们必须用一个额外的[代码]HTML[代码]页面包裹容器,来包裹生成的[代码]HTML[代码]标记。
为了简化这些,你可以直接在创建[代码]renderer[代码]时提供一个页面模板。多数时候,我们会将页面模板放在特有的文件中:
[代码]<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
[代码]
然后,我们可以读取和传输文件到[代码]Vue renderer[代码]中:
[代码]const tpl = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf-8');
const renderer = vssr.createRenderer({
template: tpl,
});
[代码]
Webpack配置
然而在实际项目中,不止上述例子那么简单,需要考虑很多方面:路由、数据预取、组件化、全局状态等,所以服务端渲染不是只用一个简单的模板,然后加上使用[代码]vue-server-renderer[代码]完成的,如下面的示意图所示:
[图片]
如示意图所示,一般的[代码]Vue[代码]服务端渲染项目,有两个项目入口文件,分别为[代码]entry-client.js[代码]和[代码]entry-server.js[代码],一个仅运行在客户端,一个仅运行在服务端,经过[代码]Webpack[代码]打包后,会生成两个[代码]Bundle[代码],服务端的[代码]Bundle[代码]会用于在服务端使用虚拟[代码]DOM[代码]生成应用程序的“快照”,客户端的[代码]Bundle[代码]会在浏览器执行。
因此,我们需要两个[代码]Webpack[代码]配置,分别命名为[代码]webpack.client.config.js[代码]和[代码]webpack.server.config.js[代码],分别用于生成客户端[代码]Bundle[代码]与服务端[代码]Bundle[代码],分别命名为[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码],关于如何配置,[代码]Vue[代码]官方有相关示例vue-hackernews-2.0
开发环境搭建
我所在的项目使用[代码]Koa[代码]作为[代码]Web Server Frame[代码],项目使用koa-webpack进行开发环境的构建。如果是在产品环境下,会生成[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码],包含对应的[代码]Bundle[代码],提供客户端和服务端引用,而在开发环境下,一般情况下放在内存中。使用[代码]memory-fs[代码]模块进行读取。
[代码]const fs = require('fs')
const path = require( 'path' );
const webpack = require( 'webpack' );
const koaWpDevMiddleware = require( 'koa-webpack' );
const MFS = require('memory-fs');
const appSSR = require('./../../app.ssr.js');
let wpConfig;
let clientConfig, serverConfig;
let wpCompiler;
let clientCompiler, serverCompiler;
let clientManifest;
let bundle;
// 生成服务端bundle的webpack配置
if ((fs.existsSync(path.resolve(cwd,'webpack.server.config.js')))) {
serverConfig = require(path.resolve(cwd, 'webpack.server.config.js'));
serverCompiler = webpack( serverConfig );
}
// 生成客户端clientManifest的webpack配置
if ((fs.existsSync(path.resolve(cwd,'webpack.client.config.js')))) {
clientConfig = require(path.resolve(cwd, 'webpack.client.config.js'));
clientCompiler = webpack(clientConfig);
}
if (serverCompiler && clientCompiler) {
let publicPath = clientCompiler.output && clientCompiler.output.publicPath;
const koaDevMiddleware = await koaWpDevMiddleware({
compiler: clientCompiler,
devMiddleware: {
publicPath,
serverSideRender: true
},
});
app.use(koaDevMiddleware);
// 服务端渲染生成clientManifest
app.use(async (ctx, next) => {
const stats = ctx.state.webpackStats.toJson();
const assetsByChunkName = stats.assetsByChunkName;
stats.errors.forEach(err => console.error(err));
stats.warnings.forEach(err => console.warn(err));
if (stats.errors.length) {
console.error(stats.errors);
return;
}
// 生成的clientManifest放到appSSR模块,应用程序可以直接读取
let fileSystem = koaDevMiddleware.devMiddleware.fileSystem;
clientManifest = JSON.parse(fileSystem.readFileSync(path.resolve(cwd,'./dist/vue-ssr-client-manifest.json'), 'utf-8'));
appSSR.clientManifest = clientManifest;
await next();
});
// 服务端渲染的server bundle 存储到内存里
const mfs = new MFS();
serverCompiler.outputFileSystem = mfs;
serverCompiler.watch({}, (err, stats) => {
if (err) {
throw err;
}
stats = stats.toJson();
if (stats.errors.length) {
console.error(stats.errors);
return;
}
// 生成的bundle放到appSSR模块,应用程序可以直接读取
bundle = JSON.parse(mfs.readFileSync(path.resolve(cwd,'./dist/vue-ssr-server-bundle.json'), 'utf-8'));
appSSR.bundle = bundle;
});
}
[代码]
渲染中间件配置
产品环境下,打包后的客户端和服务端的[代码]Bundle[代码]会存储为[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码],通过文件流模块[代码]fs[代码]读取即可,但在开发环境下,我创建了一个[代码]appSSR[代码]模块,在发生代码更改时,会触发[代码]Webpack[代码]热更新,[代码]appSSR[代码]对应的[代码]bundle[代码]也会更新,[代码]appSSR[代码]模块代码如下所示:
[代码]let clientManifest;
let bundle;
const appSSR = {
get bundle() {
return bundle;
},
set bundle(val) {
bundle = val;
},
get clientManifest() {
return clientManifest;
},
set clientManifest(val) {
clientManifest = val;
}
};
module.exports = appSSR;
[代码]
通过引入[代码]appSSR[代码]模块,在开发环境下,就可以拿到[代码]clientManifest[代码]和[代码]ssrBundle[代码],项目的渲染中间件如下:
[代码]const fs = require('fs');
const path = require('path');
const ejs = require('ejs');
const vue = require('vue');
const vssr = require('vue-server-renderer');
const createBundleRenderer = vssr.createBundleRenderer;
const dirname = process.cwd();
const siteInfo = require('./../../core/siteinfo.js').get();
const env = siteInfo.env;
let bundle;
let clientManifest;
if (env === 'development') {
// 开发环境下,通过appSSR模块,拿到clientManifest和ssrBundle
let appSSR = require('./../../core/app.ssr.js');
bundle = appSSR.bundle;
clientManifest = appSSR.clientManifest;
} else {
bundle = JSON.parse(fs.readFileSync(path.resolve(__dirname, './dist/vue-ssr-server-bundle.json'), 'utf-8'));
clientManifest = JSON.parse(fs.readFileSync(path.resolve(__dirname, './dist/vue-ssr-client-manifest.json'), 'utf-8'));
}
module.exports = async function(ctx) {
ctx.status = 200;
let html;
let context = await ctx.getTplContext();
ctx.logger('进入SSR,context为: ', JSON.stringify(context));
const tpl = fs.readFileSync(path.resolve(__dirname, './newTemplate.html'), 'utf-8');
const renderer = createBundleRenderer(bundle, {
runInNewContext: false,
template: tpl, // (可选)页面模板
clientManifest: clientManifest // (可选)客户端构建 manifest
});
ctx.logger('createBundleRenderer renderer:', JSON.stringify(renderer));
try {
html = await renderer.renderToString({
...context,
url: context.CTX.url,
});
} catch(err) {
ctx.logger('SSR renderToString 失败: ', JSON.stringify(err));
console.error(err);
}
ctx.body = html;
};
[代码]
如何对现有项目进行改造?
基本目录改造
使用[代码]Webpack[代码]来处理服务器和客户端的应用程序,大部分源码可以使用通用方式编写,可以使用[代码]Webpack[代码]支持的所有功能。
一个基本项目可能像是这样:
[代码]src
├── components
│ ├── Foo.vue
│ ├── Bar.vue
│ └── Baz.vue
├── frame
│ ├── app.js # 通用 entry(universal entry)
│ ├── entry-client.js # 仅运行于浏览器
│ ├── entry-server.js # 仅运行于服务器
│ └── index.vue # 项目入口组件
├── pages
├── routers
└── store
[代码]
[代码]app.js[代码]是我们应用程序的「通用[代码]entry[代码]」。在纯客户端应用程序中,我们将在此文件中创建根[代码]Vue[代码]实例,并直接挂载到[代码]DOM[代码]。但是,对于服务器端渲染([代码]SSR[代码]),责任转移到纯客户端[代码]entry[代码]文件。[代码]app.js[代码]简单地使用[代码]export[代码]导出一个[代码]createApp[代码]函数:
[代码]import Router from '~ut/router';
import { sync } from 'vuex-router-sync';
const Vue = require('vue');
const { createStore } = require('./../store');
import ElementUI from 'element-ui';
import vueScroll from 'vue-scroll';
import '~mstyle/ai-scan/src/assets/less/common.less';
import Frame from './index.vue';
import breastRouter from './../routers/breast';
import lungRouter from './../routers/lung';
import List from './../page/list/index.vue';
import DicomView from '~cmpt/dicom-view';
Vue.use(vueScroll);
Vue.use(ElementUI);
function createVueInstance(routes, ctx) {
let siteinfo;
if (ctx) {
siteinfo = ctx.siteinfo;
} else {
siteinfo = window.SiteInfo;
}
const router = Router({
base: siteinfo.path,
mode: 'history',
routes: [routes],
});
const store = createStore({ ctx });
// 把路由注入到vuex中
sync(store, router);
const app = new Vue({
router,
render: function(h) {
return h(Frame);
},
store,
});
return { app, router, store };
}
function createVueInstanceNoRouter(component, ctx) {
const componentIns = Vue.extend(component);
const store = createStore({ ctx });
const app = new Vue({
store,
render: h => h(component),
});
return { app, store };
}
module.exports = function createApp(ctx) {
// 通过typeof window是否为'undefined'判断当前是客户端还是服务端
// 针对不同页面创建不同的Vue实例
if (typeof window !== 'undefined') { // 客户端
if (/list/.test(window.location.pathname)) {
return createVueInstanceNoRouter(List);
} else if (/breast/.test(window.location.pathname)) {
DicomView.init('image', 'imaging');
return createVueInstance(breastRouter);
} else if (/lung/.test(window.location.pathname)) {
DicomView.init('series', 'imaging');
return createVueInstance(lungRouter);
} else if (/pacs/.test(window.location.pathname)) {
DicomView.init('series', 'imaging');
return createVueInstance(pacsRouter);
} else if (/esophagus/.test(window.location.pathname)) {
return createVueInstance(esophagusRouter);
} else {
return void console.log('The path does not match any router rule');
// do nothing
}
} else { // 服务端
if (/list/.test(ctx.path)) {
return createVueInstanceNoRouter(List, ctx);
} else if (/lung/.test(ctx.path)) {
return createVueInstance(lungRouter, ctx);
} else if (/pacs/.test(ctx.path)) {
return createVueInstance(pacsRouter);
} else if (/esophagus/.test(ctx.path)) {
return createVueInstance(esophagusRouter);
} else if (/breast/.test(ctx.path)) {
return createVueInstance(breastRouter, ctx);
} else {
return void console.log('The path does not match any router rule');
// do nothing
}
}
}
[代码]
注:在我所在的项目中,需要动态判断是否需要注册[代码]DicomView[代码],只有在客户端才初始化[代码]DicomView[代码],由于[代码]Node.js[代码]环境没有[代码]window[代码]对象,对于代码运行环境的判断,可以通过[代码]typeof window === 'undefined'[代码]来进行判断。
避免创建单例
如[代码]Vue SSR[代码]文档所述:
当编写纯客户端 (client-only) 代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。如基本示例所示,我们为每个请求创建一个新的根 Vue 实例。这与每个用户在自己的浏览器中使用新应用程序的实例类似。如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染 (cross-request state pollution)。因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例。同样的规则也适用于 router、store 和 event bus 实例。你不应该直接从模块导出并将其导入到应用程序中,而是需要在 createApp 中创建一个新的实例,并从根 Vue 实例注入。
如上代码所述,[代码]createApp[代码]方法通过返回一个返回值创建[代码]Vue[代码]实例的对象的函数调用,在函数[代码]createVueInstance[代码]中,为每一个请求创建了[代码]Vue[代码],[代码]Vue Router[代码],[代码]Vuex[代码]实例。并暴露给[代码]entry-client[代码]和[代码]entry-server[代码]模块。
在客户端[代码]entry-client.js[代码]只需创建应用程序,并且将其挂载到[代码]DOM[代码]中:
[代码]import { createApp } from './app';
// 客户端特定引导逻辑……
const { app } = createApp();
// 这里假定 App.vue 模板中根元素具有 `id="app"`
app.$mount('#app');
[代码]
服务端[代码]entry-server.js[代码]使用[代码]default export[代码] 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情 - 但是稍后我们将在此执行服务器端路由匹配和数据预取逻辑:
[代码]import { createApp } from './app';
export default context => {
const { app } = createApp();
return app;
}
[代码]
在服务端用[代码]vue-router[代码]分割代码
与[代码]Vue[代码]实例一样,也需要创建单例的[代码]vueRouter[代码]对象。对于每个请求,都需要创建一个新的[代码]vueRouter[代码]实例:
[代码]function createVueInstance(routes, ctx) {
let siteinfo;
if (ctx) {
siteinfo = ctx.siteinfo;
} else {
siteinfo = window.SiteInfo;
}
const router = Router({
base: siteinfo.path,
mode: 'history',
routes: [routes],
});
const store = createStore({ ctx });
// 把路由注入到vuex中
sync(store, router);
const app = new Vue({
router,
render: function(h) {
return h(Frame);
},
store,
});
return { app, router, store };
}
[代码]
同时,需要在[代码]entry-server.js[代码]中实现服务器端路由逻辑,使用[代码]router.getMatchedComponents[代码]方法获取到当前路由匹配的组件,如果当前路由没有匹配到相应的组件,则[代码]reject[代码]到[代码]404[代码]页面,否则[代码]resolve[代码]整个[代码]app[代码],用于[代码]Vue[代码]渲染虚拟[代码]DOM[代码],并使用对应模板生成对应的[代码]HTML[代码]字符串。
[代码]const createApp = require('./app');
module.exports = context => {
return new Promise((resolve, reject) => {
// ...
// 设置服务器端 router 的位置
router.push(context.url);
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject('匹配不到的路由,执行 reject 函数,并返回 404');
}
// Promise 应该 resolve 应用程序实例,以便它可以渲染
resolve(app);
}, reject);
});
}
[代码]
在服务端预拉取数据
在[代码]Vue[代码]服务端渲染,本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。服务端[代码]Web Server Frame[代码]作为代理服务器,在服务端对接口服务发起请求,并将数据拼装到全局[代码]Vuex[代码]状态中。
另一个需要关注的问题是在客户端,在挂载到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。
目前较好的解决方案是,给路由匹配的一级子组件一个[代码]asyncData[代码],在[代码]asyncData[代码]方法中,[代码]dispatch[代码]对应的[代码]action[代码]。[代码]asyncData[代码]是我们约定的函数名,表示渲染组件需要预先执行它获取初始数据,它返回一个[代码]Promise[代码],以便我们在后端渲染的时候可以知道什么时候该操作完成。注意,由于此函数会在组件实例化之前调用,所以它无法访问[代码]this[代码]。需要将[代码]store[代码]和路由信息作为参数传递进去:
举个例子:
[代码]<!-- Lung.vue -->
<template>
<div></div>
</template>
<script>
export default {
// ...
async asyncData({ store, route }) {
return Promise.all([
store.dispatch('getUserInfo'),
store.dispatch('lung/getSeriesByStudyId', {
studyId: route.params.id,
aiEngine: route.params.aiEngine,
}, { root:true }),
store.dispatch('lung/getDicomViewConfig', { root:true }),
store.dispatch('lung/getDialogWindow', { root:true }),
]);
},
// ...
}
</script>
[代码]
在[代码]entry-server.js[代码]中,我们可以通过路由获得与[代码]router.getMatchedComponents()[代码]相匹配的组件,如果组件暴露出[代码]asyncData[代码],我们就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文中。
[代码]const createApp = require('./app');
module.exports = context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(context.CTX);
// 针对没有Vue router 的Vue实例,在项目中为列表页,直接resolve app
if (!router) {
resolve(app);
}
// 设置服务器端 router 的位置
router.push(context.CTX.url.replace('/imaging', ''));
// 等到 router 将可能的异步组件和钩子函数解析完
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
// 匹配不到的路由,执行 reject 函数,并返回 404
if (!matchedComponents.length) {
return reject('匹配不到的路由,执行 reject 函数,并返回 404');
}
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({
store,
route: router.currentRoute,
});
}
})).then(() => {
// 在所有预取钩子(preFetch hook) resolve 后,
// 我们的 store 现在已经填充入渲染应用程序所需的状态。
// 当我们将状态附加到上下文,并且 `template` 选项用于 renderer 时,
// 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
context.state = store.state;
resolve(app);
}).catch(reject);
}, reject);
});
}
[代码]
客户端托管全局状态
当服务端使用模板进行渲染时,[代码]context.state[代码]将作为[代码]window.__INITIAL_STATE__[代码]状态,自动嵌入到最终的[代码]HTML[代码] 中。而在客户端,在挂载到应用程序之前,[代码]store[代码]就应该获取到状态,最终我们的[代码]entry-client.js[代码]被改造为如下所示:
[代码]import createApp from './app';
const { app, router, store } = createApp();
// 客户端把初始化的store替换为window.__INITIAL_STATE__
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__);
}
if (router) {
router.onReady(() => {
app.$mount('#app')
});
} else {
app.$mount('#app');
}
[代码]
常见问题的解决方案
至此,基本的代码改造也已经完成了,下面说的是一些常见问题的解决方案:
在服务端没有[代码]window[代码]、[代码]location[代码]对象:
对于旧项目迁移到[代码]SSR[代码]肯定会经历的问题,一般为在项目入口处或是[代码]created[代码]、[代码]beforeCreate[代码]生命周期使用了[代码]DOM[代码]操作,或是获取了[代码]location[代码]对象,通用的解决方案一般为判断执行环境,通过[代码]typeof window[代码]是否为[代码]'undefined'[代码],如果遇到必须使用[代码]location[代码]对象的地方用于获取[代码]url[代码]中的相关参数,在[代码]ctx[代码]对象中也可以找到对应参数。
[代码]vue-router[代码]报错[代码]Uncaught TypeError: _Vue.extend is not _Vue function[代码],没有找到[代码]_Vue[代码]实例的问题:
通过查看[代码]Vue-router[代码]源码发现没有手动调用[代码]Vue.use(Vue-Router);[代码]。没有调用[代码]Vue.use(Vue-Router);[代码]在浏览器端没有出现问题,但在服务端就会出现问题。对应的[代码]Vue-router[代码]源码所示:
[代码]VueRouter.prototype.init = function init (app /* Vue component instance */) {
var this$1 = this;
process.env.NODE_ENV !== 'production' && assert(
install.installed,
"not installed. Make sure to call `Vue.use(VueRouter)` " +
"before creating root instance."
);
// ...
}
[代码]
服务端无法获取[代码]hash[代码]路由的参数
由于[代码]hash[代码]路由的参数,会导致[代码]vue-router[代码]不起效果,对于使用了[代码]vue-router[代码]的前后端同构应用,必须换为[代码]history[代码]路由。
接口处获取不到[代码]cookie[代码]的问题:
由于客户端每次请求都会对应地把[代码]cookie[代码]带给接口侧,而服务端[代码]Web Server Frame[代码]作为代理服务器,并不会每次维持[代码]cookie[代码],所以需要我们手动把
[代码]cookie[代码]透传给接口侧,常用的解决方案是,将[代码]ctx[代码]挂载到全局状态中,当发起异步请求时,手动带上[代码]cookie[代码],如下代码所示:
[代码]// createStore.js
// 在创建全局状态的函数`createStore`时,将`ctx`挂载到全局状态
export function createStore({ ctx }) {
return new Vuex.Store({
state: {
...state,
ctx,
},
getters,
actions,
mutations,
modules: {
// ...
},
plugins: debug ? [createLogger()] : [],
});
}
[代码]
当发起异步请求时,手动带上[代码]cookie[代码],项目中使用的是[代码]Axios[代码]:
[代码]// actions.js
// ...
const actions = {
async getUserInfo({ commit, state }) {
let requestParams = {
params: {
random: tool.createRandomString(8, true),
},
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
};
// 手动带上cookie
if (state.ctx.request.headers.cookie) {
requestParams.headers.Cookie = state.ctx.request.headers.cookie;
}
// ...
let userInfoRes = await Axios.get(`${requestUrlOrigin}${url.GET_USERINFO}`, requestParams);
commit(globalTypes.SET_USERINFO, {
userInfo: userInfoRes.data,
});
}
};
// ...
[代码]
接口请求时报[代码]connect ECONNREFUSED 127.0.0.1:80[代码]的问题
原因是改造之前,使用客户端渲染时,使用了[代码]devServer.proxy[代码]代理配置来解决跨域问题,而服务端作为代理服务器对接口发起异步请求时,不会读取对应的[代码]webpack[代码]配置,对于服务端而言会对应请求当前域下的对应[代码]path[代码]下的接口。
解决方案为去除[代码]webpack[代码]的[代码]devServer.proxy[代码]配置,对于接口请求带上对应的[代码]origin[代码]即可:
[代码]let requestUrlOrigin;
if (state.ctx.URL.hostname === 'localhost' || state.ctx.URL.hostname === '127.0.0.1') { // 本地开发,转到localhost:9000
requestUrlOrigin = constParams.CONST_LOCAL_WEBSERVER_ORIGIN;
} else { // 测试环境、正式环境、盒子
requestUrlOrigin = state.ctx.URL.origin;
}
let userInfoRes = await Axios.get(`${requestUrlOrigin}${url.GET_USERINFO}`, requestParams);
[代码]
对于[代码]vue-router[代码]配置项有[代码]base[代码]参数时,初始化时匹配不到对应路由的问题
在官方示例中的[代码]entry-server.js[代码]:
[代码]// entry-server.js
import { createApp } from './app';
export default context => {
// 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
// 以便服务器能够等待所有的内容在渲染前,
// 就已经准备就绪。
return new Promise((resolve, reject) => {
const { app, router } = createApp();
// 设置服务器端 router 的位置
router.push(context.url);
// ...
});
}
[代码]
原因是设置服务器端[代码]router[代码]的位置时,[代码]context.url[代码]为访问页面的[代码]url[代码],并带上了[代码]base[代码],在[代码]router.push[代码]时应该去除[代码]base[代码],如下所示:
[代码]router.push(context.url.replace('/base', ''));
[代码]
小结
本文为笔者通过对现有项目进行改造,给现有项目加上[代码]Vue[代码]服务端渲染的实践过程的总结。
首先阐述了什么是[代码]Vue[代码]服务端渲染,其目的、本质及原理,通过在服务端使用[代码]Vue[代码]的虚拟[代码]DOM[代码],形成初始化的[代码]HTML[代码]字符串,即应用程序的“快照”。带来极大的性能优势,包括[代码]SEO[代码]优势和首屏渲染的极速体验。之后阐述了[代码]Vue[代码]服务端渲染的基本用法,即两个入口、两个[代码]webpack[代码]配置,分别作用于客户端和服务端,分别生成[代码]vue-ssr-client-manifest.json[代码]与[代码]vue-ssr-server-bundle.json[代码]作为打包结果。最后通过对现有项目的改造过程,包括对路由进行改造、数据预获取和状态初始化,并解释了在[代码]Vue[代码]服务端渲染项目改造过程中的常见问题,帮助我们进行现有项目往[代码]Vue[代码]服务端渲染的迁移。