Vue Server Rendering Practice-Time-consuming Optimization Scheme for ——Web Application’s First Screen

  javascript

With the birth and evolution of major front-end frameworks,SPAAt first it became popular. The advantage of single-page application is that it can be used without reloading the whole pageajaxAnd the whole system is realizedWebThe application refused to update, bringing the ultimate user experience. However, for needsSEO, the pursuit of extreme first-screen performance applications, front-end renderingSPAIt’s bad. luckilyVue 2.0After that, the server-side rendering was supported. It took two or three weeks for the incident to be scattered and scattered. Through the renovation of the existing project, the practice in the existing project was basically completed.VueServer rendering.

AboutVue server renderingThe principle, construction and official documents have been described in more detail. Therefore, this article is not a copy of the document, but a supplement to the document. In particular, it still takes a lot of effort to combine well with the existing projects. This paper mainly focuses on my projectVueThe transformation process of server-side rendering is described, and some personal understanding is added as sharing and learning.

summarize

This article is mainly divided into the following aspects:

  • What is server-side rendering? What is the principle of server-side rendering?
  • How is it based onKoaTheWeb Server FrameOn the configuration server rendering?

    • Basic usage
    • WebpackConfiguration
    • Construction of development environment

      • Rendering middleware configuration
  • How to reform the existing project?

    • Reform of the basic catalogue;
    • For servicevue-routerSplit code;

      • Pre-pulling data at the server;
      • The client hosts the global state;
      • Solutions to common problems;

What is server-side rendering? What is the principle of server-side rendering?

Vue.jsIs the framework for building client applications. By default, it can be output in the browser.VueComponent to generateDOMAnd operationDOM. However, the same component can also be rendered as server-sideHTMLString, send them directly to the browser, and finally “activate” these static tags as fully interactive applications on the client.

The above passage comes fromVue server rendering documentThe explanation, in popular terms, can probably be understood as follows:

  • The purpose of server-side rendering is: performance advantage. Generating a correspondingHTMLString, the client receives the correspondingHTMLString that can be rendered immediatelyDOMThe most efficient first screen takes time. In addition, because the server directly generates the correspondingHTMLString, rightSEOAlso very friendly;
  • The essence of server-side rendering is to generate “snapshots” of applications. willVueAnd that correspond library runs on the serv, at this time,Web Server FrameIn fact, it acts as a proxy server to access the interface server to pre-pull the data, thus taking the pulled data asVueThe initial state of the component.
  • The principle of server-side rendering is: virtualDOM. InWeb Server FrameAfter accessing the interface server as a proxy server to pre-pull the data, this is the data that the server needs to initialize the component. After that, the component’sbeforeCreateAndcreatedThe life cycle will be called by the server and the corresponding components will be initialized.VueEnable virtualizationDOMForming an initializedHTMLString. After that, it will be hosted by the client. And realize that isomorphic application of the front end and the rear end.

How is it based onKoaTheWeb Server FrameOn the configuration server rendering?

Basic usage

Need to useVueServer Rendering Corresponding Libraryvue-server-rendererThroughnpmInstallation:

npm install vue vue-server-renderer --save

The simplest is to render one firstVueExamples:


// 第 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>
});

Integration with server:


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;
}

Use page templates:

When you are renderingVueWhen applying the program,rendererGenerated only from applicationsHTMLMark. In this example, we must use an extraHTMLThe page wraps the container to wrap the generatedHTMLMark.

To simplify this, you can createrendererProvides a page template when. Most of the time, we will put the page template in a unique file:

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

Then, we can read and transfer files toVue rendererMedium:

const tpl = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf-8');
const renderer = vssr.createRenderer({
    template: tpl,
});

Webpack configuration

However, in actual projects, it is not as simple as the above examples, and many aspects need to be considered: routing, data prefetching, componentization, global state, etc. Therefore, server-side rendering is not only a simple template, and then added to use.vue-server-rendererCompleted, as shown in the following schematic diagram:

As shown in the schematic diagram, generallyVueThe server-side rendering project has two project entry files, namelyentry-client.jsAndentry-server.js, one only runs on the client, one only runs on the server, throughWebpackAfter packaging, two will be generatedBundle, server-sideBundleIt will be used to use virtualization on the server side.DOMGenerate a “snapshot” of the application, the client’sBundleIt will run in the browser.

Therefore, we need twoWebpackConfiguration, named respectivelywebpack.client.config.jsAndwebpack.server.config.jsWhich are used to generate clients respectivelyBundleWith the serverBundle, named respectivelyvue-ssr-client-manifest.jsonAndvue-ssr-server-bundle.jsonAbout how to configure,VueOfficials have relevant examplesvue-hackernews-2.0

Construction of development environment

My project usesKoaAs aWeb Server Frame, project usekoa-webpackBuild a development environment. If it is in the product environment, it will generatevue-ssr-client-manifest.jsonAndvue-ssr-server-bundle.jsonA that contains the correspondingBundleTo provide client-side and server-side references, while in a development environment, it is usually stored in memory. Usememory-fsThe module reads.

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;
  });
}

Rendering middleware configuration

Under the product environment, after packaging the client and serverBundleStored asvue-ssr-client-manifest.jsonAndvue-ssr-server-bundle.jsonThrough the file stream modulefsRead it, but in the development environment, I created oneappSSRModule that fires when a code change occursWebpackHot update,appSSRCorrespondingbundleIt will also be updated.appSSRThe module code is as follows:

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;

By introductionappSSRModules are available in the development environment.clientManifestAndssrBundle, the project’s rendering middleware is as follows:

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;
};

How to reform the existing project?

Basic catalog renovation

UseWebpackTo deal with server and client applications, most of the source code can be written in a common way, can useWebpackAll supported functions.

A basic project might look like this:

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.jsIs the “universal” of our applicationentry」。 In a pure client application, we will create the root in this fileVueInstance and mount it directly to theDOM. However, for server-side rendering (SSR), the responsibility is transferred to the pure cliententryDocuments.app.jsSimply useexportExport onecreateAppFunctions:

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); 
}

Note: In my project, it is necessary to dynamically judge whether registration is required.DicomView, initialized only at the clientDicomViewDue toNode.jsNo environmentwindowObject, for the judgment of the code running environment, can passtypeof window === 'undefined'To judge.

Avoid creating a single instance

Such asVue SSRThe document states:

When writing client-only code, we are used to taking values in the new context each time. However, the Node.js server is a long-running process. When our code enters the process, it will take a value and keep it in memory. This means that if a singleton object is created, it will be shared between each incoming request. As shown in the basic example, we create a new root Vue instance for each request. This is similar to the instance where each user uses a new application in their browser. If we use a shared instance among multiple requests, it is easy to cause cross-request state pollution. Therefore, instead of directly creating an application instance, we should expose a factory function that can be repeatedly executed to create a new application instance for each request. The same rules apply to router, store, and event bus instances. Instead of exporting directly from the module and importing it into the application, you need to create a new instance in createApp and inject it from the root Vue instance.

As described in the above code,createAppThe method is created by returning a return valueVueInstance of the object’s function call, in the functioncreateVueInstance, created for each requestVue,Vue Router,VuexExamples. And exposed toentry-clientAndentry-serverModules.

On the client sideentry-client.jsJust create the application and mount it to theDOMMedium:

import { createApp } from './app';

// 客户端特定引导逻辑……

const { app } = createApp();

// 这里假定 App.vue 模板中根元素具有 `id="app"`
app.$mount('#app');

Server sideentry-server.jsUsedefault exportExport the function and call it repeatedly in each rendering. At this point, it does not do much other than create and return application instances-but later we will perform server-side route matching and data prefetching logic here:

import { createApp } from './app';

export default context => {
  const { app } = createApp();
  return app;
}

For servicevue-routerSplit code

AndVueInstance, also need to create a single instance of thevueRouterObject. For each request, a new one needs to be createdvueRouterExamples:

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 };
}

At the same time, there is a need toentry-server.jsTo implement server-side routing logic in, use therouter.getMatchedComponentsThe method gets the component that the current route matches, and if the current route does not match the corresponding component, thenrejectto404Page, otherwiseresolveThe wholeappForVueRendering virtualDOMAnd use that corresponding template to generate the correspondHTMLString.

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);
  });

}

Pre-pulling data at the server

InVueServer-side rendering is essentially rendering a “snapshot” of our application, so if the application depends on some asynchronous data, it needs to prefetch and parse the data before starting the rendering process. Server sideWeb Server FrameAs a proxy server, the server initiates a request to the interface service and assembles the data to the globalVuexIn status.

Another issue that needs to be paid attention to is that the client needs to obtain exactly the same data as the server-side application before mounting the client-side application-otherwise, the client-side application will use a different state from the server-side application, and then the mixing will fail.

At present, the better solution is to give one level sub-component for route matching.asyncDataInasyncDataIn the method,dispatchCorrespondingaction.asyncDataIs our agreed function name, which means that the rendering component needs to execute it in advance to obtain initial data, and it returns a.PromiseSo that we can know when the operation is completed when we render at the back end. Note that this function cannot be accessed because it is called before the component is instantiatedthis. Need tostoreAnd routing information are passed in as parameters:

For example:

<!-- 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>

Inentry-server.jsIn, we can obtain and through routingrouter.getMatchedComponents()Matching components, if the components are exposedasyncData, we call this method. Then we need to attach the state of parsing to the rendering context.

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);
  });
}

Client Managed Global State

When the server uses templates for rendering,context.stateWill act aswindow.__INITIAL_STATE__Status, automatically embedded into finalHTMLChina. On the client side, before the application is mounted,storeWe should get the status, and eventually ourentry-client.jsIt has been modified as follows:

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');
}

Solutions to Common Problems

At this point, the basic code transformation has been completed. The following are solutions to some common problems:

  • Not on the server sidewindowlocationObject:

For old projects migrated toSSRThe problems you will definitely experience are usually at the entrance of the project orcreatedbeforeCreateLife Cycle UsedDOMOperation, or obtainedlocationObject, the common solution is generally to judge the execution environment, throughtypeof windowIs it'undefined', if encounter must uselocationObject is used to get theurlThe relevant parameters in, inctxThe corresponding parameters can also be found in the object.

  • vue-routerReport an errorUncaught TypeError: _Vue.extend is not _Vue function, not found_VueProblems with instances:

By viewingVue-routerSource code found no manual callVue.use(Vue-Router);. No callsVue.use(Vue-Router);There are no problems on the browser side, but there will be problems on the server side. CorrespondingVue-routerSource code shows:

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."
  );
  // ...
}
  • Server cannot obtainhashParameters of route

Due tohashThe parameters of the route result invue-routerIt doesn’t work, but it is used forvue-routerThe front and rear end isomorphic application of must be replaced byhistoryRouting.

  • Could not get at interfacecookieQuestion of:

Because each request of the client will correspondingly sendcookieTo the interface side, while the server sideWeb Server FrameAs a proxy server, it will not be maintained every time.cookie, so we need to manually
cookieThrough to the interface side, the common solution is toctxMounted in global state, manually loaded when asynchronous request is initiated.cookie, as shown in the following code:

// createStore.js
// 在创建全局状态的函数`createStore`时,将`ctx`挂载到全局状态
export function createStore({ ctx }) {
    return new Vuex.Store({
        state: {
            ...state,
            ctx,
        },
        getters,
        actions,
        mutations,
        modules: {
            // ...
        },
        plugins: debug ? [createLogger()] : [],
    });
}

When an asynchronous request is initiated, manually take thecookieWhich is used in the projectAxios

// 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,
    });
  }
};

// ...
  • Interface request timesconnect ECONNREFUSED 127.0.0.1:80The problem of

The reason is that before the transformation, when using client-side rendering, usingdevServer.proxyThe proxy configuration is used to solve the cross-domain problem, while the server, as a proxy server, will not read the corresponding asynchronous request to the interfacewebpackConfiguration, for the server will request the corresponding under the current domainpathThe interface under the.

The solution is to removewebpackThedevServer.proxyConfiguration, for the corresponding on the interface request tapeoriginJust:

const requestUrlOrigin = requestUrlOrigin = state.ctx.URL.origin;
const res = await Axios.get(`${requestUrlOrigin}${url.GET_A}`, requestParams);
  • Forvue-routerConfiguration items arebaseParameter, the corresponding route cannot be matched during initialization

In the official exampleentry-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);

    // ...
  });
}

The reason is to set up the server side.routerAt the same time,context.urlFor accessing the pageurlAnd brought itbaseInrouter.pushWhen should be removedbase, as follows:

router.push(context.url.replace('/base', ''));

Summary

This article is for the author to addVueSummary of the practical process of server-side rendering.

First of all, what is elaboratedVueServer rendering, its purpose, essence and principle, through the use of the serverVueVirtual ofDOMA that forms an initializedHTMLString, which is the “snapshot” of the application. Bring great performance benefits, includingSEOAdvantages and fast experience of first screen rendering. It was then explainedVueThe basic usage of server-side rendering, i.e. two entrances, twowebpackConfiguration, acting on client and server respectively, generatesvue-ssr-client-manifest.jsonAndvue-ssr-server-bundle.jsonAs a result of packaging. Finally, through the transformation process of the existing project, including the route transformation, data pre-acquisition and state initialization, this paper explains the current situation of the project in theVueCommon problems in server-side rendering project transformation process help us to carry out existing projectsVueMigration of server rendering.

At the end of the article, there is an advertisement: Tencent medical department has recruited front-end engineers, there are unlimited HC, and both social and school recruit can be pushed in. If you have a small partner who wants to come to Tencent, you can add my WeChat: xingbofeng001. If you have a small partner who wants to make friends and exchange technology, you are also welcome to add my WeChat ~