Hello!
I'll start with a little background.
I decided to try my new project on Vue.js. I needed server rendering (SSR), CSS modules, code-splitting and other delights. 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 as the project grows, it is important to have the possibility of customization. And any high-level solutions, as a rule, do not allow to do this, or give it, but with great effort (there was a similar experience with using Next.js for React).
The main problem of local development when using server rendering and hot reloading was that it is not enough to run one
webpack-dev-server . We must also do something with the sources that Node.js runs, otherwise the next time we reload the page, we’ll receive a 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 in order to be able to develop a comfortable development:
- Vuejs
- SSR
- Vuex
- CSS modules
- Code splitting
- ESLint, Prettier
In local development, all of this should be updated in the browser on the fly, and the server code should also be updated.
In production mode, bundles should be minified, a hash should be added for static caching, the paths to bundles should be automatically put in the html-template.
All this is implemented in the
repository on GitHub , I will give the code and describe the solution.
It is worth noting that Vue.js has quite comprehensive documentation for setting up server rendering, so it makes sense to
look there.
Server part
So, as a server for Node.js, we will use Express, we also need
vue-server-renderer . This package will allow us to render the code in the html-string, based on the server bundle, html-template and client manifest, in which the names and the path to the resources are indicated.
The
server.js file will 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, {
As you can see, we use 2 files:
vue-ssr-server-bundle.json and
vue-ssr-client-manifest.json .
They are generated when building the application; The first one contains the code that will be executed on the server, the second one contains the names and paths to the resources.
Also, in the
createBundleRenderer options
, we specified the
inject: false parameter. This means that it will not automatically generate html code to load resources and other things, since we need complete flexibility. In the template, we will independently mark the places where we want to output this code.
The template itself will look like this:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> {{{ meta.inject().title.text() }}} {{{ meta.inject().meta.text() }}} {{{ renderResourceHints() }}} {{{ renderStyles() }}} </head> <body> <div id="app"></div> {{{ renderState() }}} {{{ renderScripts() }}} </body> </html>
Consider more.
- Meta.inject (). Title.text () and meta.inject (). Meta.text () are needed to display headings and meta descriptions. The vue-meta package is responsible for this, which I will discuss below
- renderResourceHints () - will return rel = "preload / prefetch" links to resources specified in the client manifest
- renderStyles () - returns links to the styles specified in the client manifest
- renderState () - returns the default state of the 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. It is required.
The entry point to our Vue application from the server side is the file
entry-server.js .
import { createApp } from './app'; export default context => new Promise((resolve, 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__) {
In
app.js , our instance of Vue 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 several queries use one instance.App.vue is the root component, which contains the
<router-view> </ router-view> 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, we do not specify the components themselves, but through
() => import('./client/components/About.vue')
This is necessary for code separation (code-splitting).
As for state management (implemented by Vuex), its configuration is not distinguished by anything special. The only thing is that I have divided the module into modules and use constants with the name in order to make it easier to navigate by code.
Now consider some of the 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', }
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 are not obliged to take care of naming classes, so as not to overlap between components. Using CSS modules, the resulting class will look like
<class name> _ <hash> .
To do this, you need to specify the
style module in the component.
<style module> .item { padding: 3px 0; } .controls { margin-top: 12px; } </style>
And in the template, specify the attribute
: class <div :class="$style.item"></div>
Also, it is necessary to specify in the settings of the webpack that we will use the modules.
Assembly
Let us turn to the webpack settings themselves.
We have a basic config that inherit 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 processing.
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]', }, }, }, ], }, });
First, all the CSS processing I had was moved to the base config, since it is needed both on the client and on the server. There also occurred minification for the production regime.
However, I ran into the problem that a
document appeared on the server side, and, accordingly, an error occurred. This turned out to be
a mini-css-extract-plugin error , which was fixed by dividing the CSS processing for the server and client.
VueSSRServerPlugin generates the file
vue-ssr-server-bundle.json , 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;
Of noteworthy, in local development, we specify the
publicPath that refers to the
webpack-dev-server and generate the name of the file without the hash. Also, for
devServer we specify the
writeToDisk parameter
: true .
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, since 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 pulled.
In fact, in the future I want to get rid of it. One of the solutions is in virgins. In
server.js mode, connect the manifest not from the
/ dist directory, but from the url of the dev server. But in this case, it becomes an asynchronous operation. I will be glad to a beautiful solution to the problem in the comments.
Nodemon is responsible for server side
reloading , which monitors the two files:
dist / vue-ssr-server-bundle.json and
app / server.js and, when changed, restarts the application.
To be able to restart the application when changing
server.js , we do not specify this file as an input point in
nodemon , but create a file
nodemon.js , in which we include
server.js . And the input point is the
nodemon.js file.
In production mode, the input point is
app / server.js .
Conclusion
Total, we have a repository with settings and several teams.
For local development: yarn run dev
On the client side: launches a
webpack-dev-server , which monitors the change in Vue components and just the 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.
From the server side: launches the
webpack in monitoring mode,
builds the server bundle (
vue-ssr-server-bundle.json ) and when changed, restarts the application.
In this case, the code changes consistently on the client and the server automatically.
When you first start it may be an error that the server bundle is not found. This is normal. Just need to restart the command.
For production builds: 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 paths to the resources.
From the server side: builds a server bundle.
Also, I also created the
yarn run start-node command, which starts
server.js , but this is done for example only, in a production application, you should use process managers, for example, PM2, to start.
I hope that the described experience will help to quickly set up the ecosystem for comfortable work and focus on the development of functionality.
useful links