登陆

Vue 服务端烘托实践——Web使用首屏耗时最优化计划

admin 2019-09-07 301人围观 ,发现0个评论



作者: counterxinghttps://segmentfault.com/a/1190000018577041

跟着各大前端结构的诞生和演化, SPA开端盛行,单页面运用的优势在于能够不从头加载整个页面的情况下,经过 ajax和服务器通讯,完结整个 Web运用拒不更新,带来了极致的用户体会。

可是,关于需求 SEO、寻求极致的首屏功用的运用,前端烘托的 SPA是糟糕的。

好在 Vue 2.0后是支撑服务端烘托的,零零散散花费了两三周工作,经过改造现有项目,根本完结了在现有项目中实践了 Vue服务端烘托。

关于Vue服务端烘托的原理、建立,官方文档现已讲的比较详细了,因而,本文不是抄袭文档,而是文档的弥补。

特别是关于怎么与现有项目进行很好的结合,仍是需求费很大功夫的。本文首要对我地点的项目中进行 Vue服务端烘托的改造进程进行论述,加上一些个人的了解,作为同享与学习。

概述

本文首要分以下几个方面:

  • 什么是服务端烘托?服务端烘托的原理是什么?
  • 怎么在根据 Koa的 Web Server Frame上装备服务端烘托?
  • 怎么对现有项目进行改造?
  • 在服务端预拉取数据;
  • 客户端保管大局状况;
  • 常见问题的处理方案;
  • 根本目录改造;
  • 在服务端用 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: `
Hello World
`
});
// 第 2 步:创立一个 renderer
const renderer = require('vue-server-renderer').createRenderer();
// 第 3 步:将 Vue 实例烘托为 HTML
renderer.renderToString(app, (err, html) => {
if (err) {
throw err;
}
console.log(html);
// =>
Hello World

});

与服务器集成:

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时供给一个页面模板。大都时分,咱们会将页面模板放在特有的文件中:








然后,咱们能够读取和传输文件到 Vue renderer中:

const tVue 服务端烘托实践——Web使用首屏耗时最优化计划pl = 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.wVue 服务端烘托实践——Web使用首屏耗时最优化计划atch({}, (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 env = process.env.RUN_ENVIRONMENT;
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';
import Vue from 'vue';
import { createStore } from './../store';
import Frame from './index.vue';
import myRouter from './../routers/myRouter';
function createVueInstance(routes, ctx) {
const router = Router({
base: '/base',
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 };
}
module.exports = function createApp(ctx) {
return createVueInstance(myRouter, ctx);
}

注:在我地点的项目中,需求动态判别是否需求注册 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, VueRouter, 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) {
const router = Router({
base: '/base',
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 Vue 服务端烘托实践——Web使用首屏耗时最优化计划函数,并回来 404
if (!matchedComponents.length) {
return reject('匹配不到的路由,履行 reject 函数,并回来 404');
}
// Promise 应该 resolve 运用程序实例,以便它能够烘托
resolve(app);
}, reject);
});
}

在服务端预拉取数据

在 Vue服务端烘托,实质上是在烘托咱们运用程序的"快照",所以假如运用程序依赖于一些异步数据,那么在开端烘托进程之前,需求先预取和解析好这些数据。

服务端 WebServer Frame作为署理服务器,在服务端对接口服务建议恳求,并将数据拼装到大局 Vuex状况中。

另一个需求重视的问题是在客户端,在挂载到客户端运用程序之前,需求获取到与服务器端运用程序彻底相同的数据 - 不然,客户端运用程序会由于运用与服务器端运用程序不同的状况,然后导致混合失利。

现在较好的处理方案是,给路由匹配的一级子组件一个 asyncData,在 asyncData办法中, dispatch对应的 action。

asyncData是咱们约好的函数名,表明烘托组件需求预先履行它获取初始数据,它回来一个 Promise,以便咱们在后端烘托的时分能够知道什么时分该操作完结。

留意,由于此函数会在组件实例化之前调用,所以它无法拜访 this。需求将 store和路由信息作为参数传递进去:

举个比如:




在 entry-server.js中,咱们能够经过路由获得与 router.getMatchedComponents()相匹配的组件,假如组件露出出 asyncData,咱们就调用这个办法。

然后咱们需求将解析完结的状况,附加到烘托上下文中。

const createApp = require('./app');
module.exports = context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(context);
// 针对没有Vue router 的Vue实例,在项目中为列表页,直接resolve app
if (!router) {
resolve(app);
}
// 设置服务器端 router 的方位
router.push(coVue 服务端烘托实践——Web使用首屏耗时最优化计划ntext.url.replace('/base', ''));
// 比及 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');
}

常见问题的处理方案

至此,根本的代码改造也现已完结了,下面说的是一些常见问题的处理方案:

关于旧项目搬迁到 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 !== 'productionVue 服务端烘托实践——Web使用首屏耗时最优化计划' && assert(
install.installed,
"not installed. Make sure to call `Vue.use(VueRouter)` " +
"before creating root instance."
)恋爱的犀牛;
// ...
}

由于 hash路由的参数,会导致 vue-router不起作用,关于运用了 vue-router的前后端同构运用,有必要换为 history路由。

由于客户端每次恳求都会对应地把 cookie带给接口侧,而服务端 Web ServerFrame作为署理服务器,并不会每次保持 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 res = await Axios.get(`${requestUrlOrigin}${url.GET_A}`, requestParams);
commit(globalTypes.SET_A, {
res: res.data,
});
}
};
// ...
  • 接口恳求时报 connect ECONNREFUSED 127.0.0.1:80的问题

原因是改造之前,运用客户端烘托时,运用了 devServer.proxy署理装备来处理跨域问题,而服务端作为署理服务器对接口建议异步恳求时,不会读取对应的 webpack装备,关于服务端而言会对应恳求当时域下的对应 path下的接口。

处理方案为去除 webpack的 devServer.proxy装备,关于接口恳求带上对应的 origin即可:

const requestUrlOrigin = requestUrlOrigin = state.ctx.URL.origin;
const res = await Axios.get(`${requestUrlOrigin}${url.GET_A}`, 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服务端烘托的搬迁。


以上便是今日的同享啦~

假如我们有问题或许想了解更多的

技能干货能够加朗妹儿微信哟~

请关注微信公众号
微信二维码
不容错过
Powered By Z-BlogPHP