How I made development on Vue.js convenient with server-side rendering

Hello!

I'll start with a little background.

I decided to try my new project on Vue.js. I needed server-side rendering (SSR), CSS modules, code-splitting, and other goodies. Of course, a hot reboot (HMR) was needed to increase development productivity.

I did not want to use ready-made solutions, such as Nuxt.js, because when the project grows, it is important to be able to customize. And any high-level solutions, as a rule, do not allow to do this, or give, but with great efforts (there was a similar experience using Next.js for React).

The main problem of local development when using server rendering and a hot reboot was that it was not enough to run one webpack-dev-server. We must also do something with the sources that Node.js launches, otherwise the next time we reload the page, we will get code that was not updated on the server but updated on the client.

Having plunged into the documentation and the Internet, I, unfortunately, did not find ready-made adequately working examples and templates. Therefore, I created my own.



I determined what my template should consist of so that you can lead a comfortable development:

  • Vuejs
  • SSR
  • Vuex
  • CSS modules
  • Code splitting
  • ESLint, Prettier

With local development, all this should be updated in the browser on the fly, and server code should also be updated.

In production mode, bundles should be minified, a hash should be added for caching statics, paths to bundles should be automatically set in the html template.

All this is implemented in the repository on GitHub , I will provide the code and describe the solutions.

It is worth noting that Vue.js has quite comprehensive documentation for setting up server rendering, so it makes sense to look there .

Server side


So, we will use Express as the server for Node.js, we also need vue-server-renderer . This package will allow us to render the code into an html-string, based on the server bundle, html-template and client manifest, in which the names and path to the resources are indicated.

The server.js file will eventually look like this:

const path = require('path');
const express = require('express');
const { createBundleRenderer } = require('vue-server-renderer');
const template = require('fs').readFileSync(
  path.join(__dirname, './templates/index.html'),
  'utf-8',
);
const serverBundle = require('../dist/vue-ssr-server-bundle.json');
const clientManifest = require('../dist/vue-ssr-client-manifest.json');
const server = express();
const renderer = createBundleRenderer(serverBundle, {
  // с этим параметром код сборки будет выполняться в том же контексте, что и серверный процесс
  runInNewContext: false,
  template,
  clientManifest,
  inject: false,
});
// в боевом проекте имеет смысл раздавать статику с nginx
server.use('/dist', express.static(path.join(__dirname, '../dist')));
server.get('*', (req, res) => {
  const context = { url: req.url };
  renderer.renderToString(context, (err, html) => {
    if (err) {
      if (+err.message === 404) {
        res.status(404).end('Page not found');
      } else {
        console.log(err);
        res.status(500).end('Internal Server Error');
      }
    }
    res.end(html);
  });
});
server.listen(process.env.PORT || 3000);

As you can see, we use 2 files: vue-ssr-server-bundle.json and vue-ssr-client-manifest.json .

They are generated when the application is built; in the first is the code that will be executed on the server, the second contains the names and paths to the resources.

Also, in the createBundleRenderer options , we specified the inject: false parameter . This means that there will be no automatic generation of html code for loading resources and other things, because we need complete flexibility. In the template, we will independently mark the places where we want to display this code.

The template itself will look like this:


    {{{ meta.inject().title.text() }}}
    {{{ meta.inject().meta.text() }}}
    {{{ renderResourceHints() }}}
    {{{ renderStyles() }}}


{{{ renderState() }}} {{{ renderScripts() }}}

Let's consider in more detail.

  • meta.inject (). title.text () and meta.inject (). meta.text () are needed to display headers and meta descriptions. The vue-meta package is responsible for this , about which I will discuss below
  • renderResourceHints () - will return rel = "preload / prefetch" links to resources specified in the client manifest
  • renderStyles () - will return links to styles specified in the client manifest
  • renderState () - will return the default state in window .__ INITIAL_STATE__
  • renderScripts () - will return the scripts necessary for the application to work

Instead of a comment, the markup of our application will be substituted. He is required.

The entry point to our Vue server-side application is the entry-server.js file .

import { createApp } from './app';
export default context =>
  new Promise((resolve, reject) => { 
    // на каждый запрос создается экземпляр Vue
    const { app, router, store } = createApp();
    // $meta - метод, добавляемый пакетом vue-meta в экземпляр Vue
    const meta = app.$meta();
    // пушим текущий путь в роутер
    router.push(context.url);
    // записываем мета-данные в контекст, чтобы потом отрендерить в шаблоне
    context.meta = meta;
    router.onReady(() => {
      context.rendered = () => {
        // записываем стейт в контекст, в шаблоне он будет сгенерирован, как window.__INITIAL_STATE__
        context.state = store.state;
      };
      const matchedComponents = router.getMatchedComponents();
      // если ничего не нашлось
      if (!matchedComponents.length) {
        return reject(new Error(404));
      }
      return resolve(app);
    }, reject);
  });

Client part


The client-side entry point is the entry-client.js file .

import { createApp } from './app';
const { app, router, store } = createApp();
router.onReady(() => {
  if (window.__INITIAL_STATE__) {
    // заменяет стейт на тот, что пришел с сервера
    store.replaceState(window.__INITIAL_STATE__);
  }
  app.$mount('#app');
});
// этот код активирует HMR и сработает, когда webpack-dev-server будет запущен со свойством hot
if (module.hot) {
  const api = require('vue-hot-reload-api');
  const Vue = require('vue');
  api.install(Vue);
  if (!api.compatible) {
    throw new Error(
      'vue-hot-reload-api is not compatible with the version of Vue you are using.',
    );
  }
  module.hot.accept();
}

In app.js , our Vue instance is created, which is then used both on the server and on the client.

import Vue from 'vue';
import { sync } from 'vuex-router-sync';
import { createRouter } from './router';
import { createStore } from './client/store';
import App from './App.vue';
export function createApp() {
  const router = createRouter();
  const store = createStore();
  sync(store, router);
  const app = new Vue({
    router,
    store,
    render: h => h(App),
  });
  return { app, router, store };
}

We always create a new instance to avoid a situation where multiple requests use the same instance.

App.vue is the root component that contains the directive, which will substitute the necessary components, depending on the route.

The router itself looks like this

import Vue from 'vue';
import Router from 'vue-router';
import VueMeta from 'vue-meta';
import routes from './routes';
Vue.use(Router);
Vue.use(VueMeta);
export function createRouter() {
  return new Router({
    mode: 'history',
    routes: [
      { path: routes.pages.main, component: () => import('./client/components/Main.vue') },
      { path: routes.pages.about, component: () => import('./client/components/About.vue') },
    ],
  });
}

Through Vue.use we connect two plugins: Router and VueMeta .
In routes, the components themselves are not indicated directly, but through

() => import('./client/components/About.vue')

This is for code splitting.

As for state management (implemented by Vuex), its configuration is nothing special. The only thing is, I divided the side into modules and use constants with a name to make it easier to navigate by code.

Now consider a few nuances in the Vue components themselves.

The metaInfo property is responsible for rendering meta data using the vue-meta package . You can specify a large number of various parameters ( more ).

metaInfo: {
    title: 'Main page',
}

The components have a method that runs only on the server side.

serverPrefetch() {
    console.log('Run only on server');
}

Also, I wanted to use CSS modules. I like the idea when you don’t have to care about the name of the classes so as not to overlap between the components. Using CSS modules, the resulting class will look like <class name> _ <hash> .

To do this, you need to specify a style module in the component .


And in the template specify the attribute : class


Also, it is necessary to specify in the webpack settings that we will use the modules.

Assembly


Let's move on to the webpack settings themselves.

We have a basic config that is inherited by the config for the server and client parts.

const webpack = require('webpack');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const merge = require('webpack-merge');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const isProduction = process.env.NODE_ENV === 'production';
let config = {
  mode: isProduction ? 'production' : 'development',
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: file => /node_modules/.test(file) && !/\.vue\.js/.test(file),
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000,
            name: 'images/[name].[hash:8].[ext]',
          },
        },
      },
    ],
  },
  plugins: [
    new VueLoaderPlugin(),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
    }),
  ],
};
if (isProduction) {
  config = merge(config, {
    optimization: {
      minimizer: [new OptimizeCSSAssetsPlugin(), new UglifyJsPlugin()],
    },
  });
}
module.exports = config;

The config for building server code is no different from the one in the documentation . Except for CSS handling.

const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const baseConfig = require('./webpack.base.js');
module.exports = merge(baseConfig, {
  entry: './app/entry-server.js',
  target: 'node',
  devtool: 'source-map',
  output: {
    libraryTarget: 'commonjs2',
  },
  externals: nodeExternals({
    whitelist: /\.css$/,
  }),
  plugins: [new VueSSRServerPlugin()],
  module: {
    rules: [
      {
        test: /\.css$/,
        loader: 'css-loader',
        options: {
          modules: {
            localIdentName: '[local]_[hash:base64:8]',
          },
        },
      },
    ],
  },
});

At first, all CSS processing was moved to the base config, because it is needed both on the client and on the server. There was a minification for the production regime.
However, I ran into a problem that the server turned out to be document , and, accordingly, an error occurred. This turned out to be a mini-css-extract-plugin error that was fixed by splitting CSS processing for the server and client.

VueSSRServerPlugin generates the vue-ssr-server-bundle.json file , which indicates the code that runs on the server.

Now consider the client config.

const webpack = require('webpack');
const merge = require('webpack-merge');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const path = require('path');
const baseConfig = require('./webpack.base.js');
const isProduction = process.env.NODE_ENV === 'production';
let config = merge(baseConfig, {
  entry: ['./app/entry-client.js'],
  plugins: [new VueSSRClientPlugin()],
  output: {
    path: path.resolve('./dist/'),
    filename: '[name].[hash:8].js',
    publicPath: '/dist/',
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          isProduction ? MiniCssExtractPlugin.loader : 'vue-style-loader',
          {
            loader: 'css-loader',
            options: {
              modules: {
                localIdentName: '[local]_[hash:base64:8]',
              },
            },
          },
        ],
      },
    ],
  },
});
if (!isProduction) {
  config = merge(config, {
    output: {
      filename: '[name].js',
      publicPath: 'http://localhost:9999/dist/',
    },
    plugins: [new webpack.HotModuleReplacementPlugin()],
    devtool: 'source-map',
    devServer: {
      writeToDisk: true,
      contentBase: path.resolve(__dirname, 'dist'),
      publicPath: 'http://localhost:9999/dist/',
      hot: true,
      inline: true,
      historyApiFallback: true,
      port: 9999,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
    },
  });
} else {
  config = merge(config, {
    plugins: [
      new MiniCssExtractPlugin({
        filename: '[name].[hash:8].css',
      }),
    ],
  });
}
module.exports = config;

From the noteworthy, in local development, we specify publicPath , referencing webpack-dev-server, and generate the file name without a hash. Also, for devServer we specify the writeToDisk: true parameter .

An explanation is needed here.

By default, webpack-dev-server distributes resources from RAM without writing them to disk. In this case, we are faced with the problem that in the client manifest ( vue-ssr-client-manifest.json), which is located on the disk, irrelevant resources will be indicated, because It will not be updated. To get around this, we tell the dev server to write the changes to disk, in which case the client manifest will be updated and the necessary resources will be tightened.

In fact, in the future I want to get rid of this. One solution is in maidens. mode in server.js to connect the manifest not from the / dist directory , but from the server url. But in this case, it becomes an asynchronous operation. I will be glad to have a nice solution to the problem in the comments.

Nodemon is responsible for server side reloading , which monitors two files: dist / vue-ssr-server-bundle.json and app / server.jsand when they change, the application restarts.

To be able to restart the application when changing server.js , we do not specify this file as an entry point in nodemon , but create a nodemon.js file into which we connect server.js . And the nodemon.js file becomes the entry point .

In production mode, app / server.js becomes the entry point .

Conclusion


Total, we have a repository with settings and several teams.

For local development:

yarn run dev

On the client side: it launches webpack-dev-server , which monitors changes in Vue components and simple code, generates a client manifest with paths to the dev server, saves it to disk and updates the code, styles on the fly in the browser.

On the server side: it starts webpack in monitoring mode, collects the server bundle ( vue-ssr-server-bundle.json ) and restarts the application when it is changed.

In this case, the code changes consistently on the client and server automatically.
At the first start, an error may occur that the server bundle was not found. This is normal. Just need to restart the command.

For assembly production:

yarn run build

On the client side: collects and minifies js and css, adding a hash to the name and generates a client manifest with relative resource paths.

From the server side: collects a server bundle.

Also, I created another yarn run start-node command , which starts server.js , however this is done only as an example, in a production application to start it is worth using process managers, for example, PM2.

I hope that the experience described will help to quickly set up the ecosystem for comfortable work and focus on the development of functionality.

useful links



Also popular now: