📜 ⬆️ ⬇️

How I made it easy to develop on Vue.js 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 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:


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, { //           ,     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 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"><!--vue-ssr-outlet--></div> {{{ renderState() }}} {{{ renderScripts() }}} </body> </html> 

Consider more.


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) => { //      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 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


Source: https://habr.com/ru/post/458396/


All Articles