视频1 视频21 视频41 视频61 视频文章1 视频文章21 视频文章41 视频文章61 推荐1 推荐3 推荐5 推荐7 推荐9 推荐11 推荐13 推荐15 推荐17 推荐19 推荐21 推荐23 推荐25 推荐27 推荐29 推荐31 推荐33 推荐35 推荐37 推荐39 推荐41 推荐43 推荐45 推荐47 推荐49 关键词1 关键词101 关键词201 关键词301 关键词401 关键词501 关键词601 关键词701 关键词801 关键词901 关键词1001 关键词1101 关键词1201 关键词1301 关键词1401 关键词1501 关键词1601 关键词1701 关键词1801 关键词1901 视频扩展1 视频扩展6 视频扩展11 视频扩展16 文章1 文章201 文章401 文章601 文章801 文章1001 资讯1 资讯501 资讯1001 资讯1501 标签1 标签501 标签1001 关键词1 关键词501 关键词1001 关键词1501 专题2001
Vue服务端渲染实践之Web应用首屏耗时最优化方案
2020-11-27 21:59:52 责编:小采
文档


上面这段话是源自Vue服务端渲染文档的解释,用通俗的话来说,大概可以这么理解:

  • 服务端渲染的目的是:性能优势。 在服务端生成对应的HTML字符串,客户端接收到对应的HTML字符串,能立即渲染DOM,最高效的首屏耗时。此外,由于服务端直接生成了对应的HTML字符串,对SEO也非常友好;
  • 服务端渲染的本质是:生成应用程序的“快照”。将Vue及对应库运行在服务端,此时,Web Server Frame实际上是作为代理服务器去访问接口服务器来预拉取数据,从而将拉取到的数据作为Vue组件的初始状态。
  • 服务端渲染的原理是:虚拟DOM。在Web Server Frame作为代理服务器去访问接口服务器来预拉取数据后,这是服务端初始化组件需要用到的数据,此后,组件的beforeCreatecreated生命周期会在服务端调用,初始化对应的组件后,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.jsentry-server.js,一个仅运行在客户端,一个仅运行在服务端,经过Webpack打包后,会生成两个Bundle,服务端的Bundle会用于在服务端使用虚拟DOM生成应用程序的“快照”,客户端的Bundle会在浏览器执行。

    因此,我们需要两个Webpack配置,分别命名为webpack.client.config.jswebpack.server.config.js,分别用于生成客户端Bundle与服务端Bundle,分别命名为vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json,关于如何配置,Vue官方有相关示例vue-hackernews-2.0

    开发环境搭建

    我所在的项目使用Koa作为Web Server Frame,项目使用koa-webpack进行开发环境的构建。如果是在产品环境下,会生成vue-ssr-client-manifest.jsonvue-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.jsonvue-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模块,在开发环境下,就可以拿到clientManifestssrBundle,项目的渲染中间件如下:

    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中,为每一个请求创建了VueVue RouterVuex实例。并暴露给entry-cliententry-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方法获取到当前路由匹配的组件,如果当前路由没有匹配到相应的组件,则reject404页面,否则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对应的actionasyncData是我们约定的函数名,表示渲染组件需要预先执行它获取初始数据,它返回一个Promise,以便我们在后端渲染的时候可以知道什么时候该操作完成。注意,由于此函数会在组件实例化之前调用,所以它无法访问this。需要将store和路由信息作为参数传递进去:

    举个例子:

    <!-- Lung.vue -->
    <template>
     <div></div>
    </template>
    
    <script>
    export default {
     // ...
     async asyncData({ store, route }) {
     return Promise.all([
     store.dispatch('getA'),
     store.dispatch('myModule/getB', { root:true }),
     store.dispatch('myModule/getC', { root:true }),
     store.dispatch('myModule/getD', { 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);
     // 针对没有Vue router 的Vue实例,在项目中为列表页,直接resolve app
     if (!router) {
     resolve(app);
     }
     // 设置服务器端 router 的位置
     router.push(context.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');
    }

    常见问题的解决方案

    至此,基本的代码改造也已经完成了,下面说的是一些常见问题的解决方案:

  • 在服务端没有windowlocation对象:
  • 对于旧项目迁移到SSR肯定会经历的问题,一般为在项目入口处或是createdbeforeCreate生命周期使用了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 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下的接口。

    解决方案为去除webpackdevServer.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.jsonvue-ssr-server-bundle.json作为打包结果。最后通过对现有项目的改造过程,包括对路由进行改造、数据预获取和状态初始化,并解释了在Vue服务端渲染项目改造过程中的常见问题,帮助我们进行现有项目往Vue服务端渲染的迁移。

    下载本文
    显示全文
    专题