Viblo CTF
+5

Improving Webpack build performance and verbosity

Quick guide for Single Page Applications with Webpack 2

In previous article we set up a simple single page application with routing and web pack builds.

Now we extend the application with better logic and using more web pack features and tools.

Analyzing the builds

Currently app is relatively small and we don’t get any really big files.

                         Asset       Size  Chunks                    Chunk Names
     0.6bcce77c5e562c64a428.js    76.8 kB       0  [emitted]
     1.6bcce77c5e562c64a428.js    76.8 kB       1  [emitted]
   app.6bcce77c5e562c64a428.js     487 kB       2  [emitted]  [big]  app
style.6bcce77c5e562c64a428.css     135 kB       2  [emitted]         app
                    index.html  380 bytes          [emitted]

So let’s ”harden” it with some additional features.

$ npm i highlight.js remarkable moment lodash

All these libraries we include in different parts of the application. You can see their usage in This commit: rkgrep/[email protected]

Now, after we run build we receive much different stats:

Hash: e97129f3c59b62c46515
Version: webpack 3.5.5
Time: 3347ms
                         Asset       Size  Chunks                    Chunk Names
     0.e97129f3c59b62c46515.js     864 kB       0  [emitted]  [big]
     1.e97129f3c59b62c46515.js     818 kB       1  [emitted]  [big]
     2.e97129f3c59b62c46515.js    1.08 MB       2  [emitted]  [big]
   app.e97129f3c59b62c46515.js     501 kB       3  [emitted]  [big]  app
style.e97129f3c59b62c46515.css     135 kB       3  [emitted]         app
                    index.html  380 bytes          [emitted]

So what can we do with that? 😟

Webpack performance setup

Official docs: Performance

Name of this setup is not much obvious: it does not increase performance, but instead creates additional warnings on issues.

Let’s include it in our config file

base.config.js

// ... inside config object
    performance: {
        hints: 'warning',
        maxEntrypointSize: 400000,
        maxAssetSize: 300000,
    }
// ...

Now Webpack will provide warnings if entrypoint size exceeds 400 kB or any asset size exceeds 300 kB

WARNING in asset size limit: The following asset(s) exceed the recommended size limit (300 kB).
This can impact web performance.
Assets:
  0.1bfb05e210c1b9dc777e.js (864 kB)
  1.1bfb05e210c1b9dc777e.js (818 kB)
  2.1bfb05e210c1b9dc777e.js (1.08 MB)
  app.1bfb05e210c1b9dc777e.js (501 kB)

WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (400 kB). This can impact web performance.
Entrypoints:
  app (636 kB)
      app.1bfb05e210c1b9dc777e.js
      style.1bfb05e210c1b9dc777e.css

Analyzing bundles

HTML report

In order to see details of every bundle we need webpack-bundle-analyzer plugin.

$ npm i -D webpack-bundle-analyzer

base.config.js

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')

// ...
    plugins: [
        // ...
        new BundleAnalyzerPlugin({
            analyzerMode: isProd ? 'static' : 'disabled',
        }),
    ],
// ...

Now, after each build, we will get a webpage with diagram showing size of each chunk and libraries inside:

Bundle analysis

As you can see, moment library had been included into 2 chunks where it is being used as well as handlebars library. This is both advantage and disadvantage of this approach, but in our case it is likely not to have such inclusions, but separate these libraries into their own file.

You can disable automatic display of report by setting openAnalyzer option to false

Stats file analyzing

If you want more details, you can generate special JSON file containing all profiling information.

To do this, enable profile option in web pack config and generateStatsFile in bundle analyzer config.

base.config.js

// ... in config object
   plugins: [
        // ...
        new BundleAnalyzerPlugin({
            analyzerMode: isProd ? 'static' : 'disabled',
            openAnalyzer: false,
            generateStatsFile: isProd,
        }),
    ],

    profile: isProd,
// ...

Now, in addition to report.hmtl, a JSON file is created named stats.json You can upload this file to https://webpack.github.io/analyse/ to visualize details.

Webpack analyze report

There is a lot of information, including time spent on compilation and all module dependencies.

Manual chunks and DLLs

So what should we do with our huge files?

First, we can tune up entry points by creating additional vendor file, which will contain our libraries separately.

Common chunks

In order to do this, we use CommonChunksPlugin, bundled within web pack.optimize object

base.config.js

const {
    ProvidePlugin,
    optimize: {
        CommonsChunkPlugin,
    }
} = require('webpack')

// ...
    entry: {
        vendor: [
            'jquery',
            'bootstrap',
            'moment',
            'handlebars',
        ],
        // ...
    },
    // ...
    plugins: [
        // ...
        new CommonsChunkPlugin({
            name: 'vendor',
            minChunks: Infinity,
        })
    ],
// ...

After this setup, libraries included in vendor entry will be separated into special chunk.

Hash: 0387c3e3ccbd4183fa38
Version: webpack 3.5.5
Time: 3147ms
                         Asset       Size  Chunks                    Chunk Names
     0.0387c3e3ccbd4183fa38.js     787 kB       0  [emitted]  [big]
     1.0387c3e3ccbd4183fa38.js     276 kB       1  [emitted]
     2.0387c3e3ccbd4183fa38.js     542 kB       2  [emitted]  [big]
   app.0387c3e3ccbd4183fa38.js    33.4 kB       3  [emitted]         app
vendor.0387c3e3ccbd4183fa38.js    1.01 MB       4  [emitted]  [big]  vendor
style.0387c3e3ccbd4183fa38.css     135 kB       3  [emitted]         app
                    index.html  458 bytes          [emitted]

Of course, the chunk is still big (1.01 MB), but at least, it is a single file.

DLLs

In webpack DDLs are separate builds, which provide a manifest file describing included libraries.

DLLs are implemented with separate web pack configs and 2 plugins: DLLPlugin, which describes DLL contents and DLLReferencePlugin, which allows to inject DLL into main config.

We will come back to DLLs on next steps of this tutorial.

Compression and minification

The very important step is to minify your files as much as possible.

Uglify JS plugin

The main plugin for magnification is Uglify JS, which is available as webpack.optimize.UglifyJsPlugin or as uglifyjs-webpack-plugin package.

base.config.js

const {
    ProvidePlugin,
    optimize: {
        CommonsChunkPlugin,
        UglifyJsPlugin,
    }
} = require('webpack')

// ...

    plugins: [
        // ...
        new UglifyJsPlugin(),
    ],

// ...

Now, after we run the build, scripts will become much smaller and suitable to serve online:

                         Asset       Size  Chunks                    Chunk Names
     0.02af493962bed643e207.js     559 kB       0  [emitted]  [big]
     1.02af493962bed643e207.js    93.3 kB       1  [emitted]
     2.02af493962bed643e207.js    72.1 kB       2  [emitted]
   app.02af493962bed643e207.js    14.4 kB       3  [emitted]         app
vendor.02af493962bed643e207.js     450 kB       4  [emitted]  [big]  vendor
style.02af493962bed643e207.css     135 kB       3  [emitted]         app
                    index.html  458 bytes          [emitted]

When your project grows up, uglification will take a lot of time to process, as it requires to analyze and transform all the code.

For such cases, there is parallel option, which can be turned on by setting to true or fine-tuned with additional parameters.

new UglifyJsPlugin({
    parallel: true,
}),

CSS minification

As you can see, styles.css file was not compressed at all. In order to do that, we need to tune css-loader options.

base.config.js

const cssLoader = {
    loader: 'css-loader',
    options: {
        minimize: true,
    },
}

// ...

    module: {
        rules: [
            // ...
            {
                test: /\.css$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: [cssLoader],
                }),
            },
            {
                test: /\.scss|\.sass$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: [cssLoader, 'sass-loader'],
                }),
            },
            // ...
        ],
    },

This will enable CSSNano minification.

Webpack supports loader options to be passed as objects or query strings. For example, the config above can be replaced by 'css-loader? minimize=true'

Now, CSS file size has been decreased by ~20%

style.5901fd90da81029ce30e.css     106 kB       3  [emitted]         app

Context replacement

Moment.js locales

If you take a look at bundle report, you can notice that there are plenty of unnecessary files included in the build. For example, locales for moment.js library.

The reason for that is this piece of code in moment sources moment/moment · GitHub:

function loadLocale(name) {
    var oldLocale = null;
    // TODO: Find a better way to register and load all the locales in Node
    if (!locales[name] && (typeof module !== 'undefined') &&
            module && module.exports) {
        try {
            oldLocale = globalLocale._abbr;
            require('./locale/' + name);
            // because defineLocale currently also sets the global locale, we
            // want to undo that for lazy loaded locales
            getSetGlobalLocale(oldLocale);
        } catch (e) { }
    }
    return locales[name];
}

When webpack collects modules to include in bundles, require function called with expressions automatically loads all files in the directory. Which means that require('./locale/' + name); will load all locales even if they are not used in your application.

For such cases, you can use ContextReplacementPlugin to limit expressions resolving.

const {
    ProvidePlugin,
    ContextReplacementPlugin,
    optimize: {
        CommonsChunkPlugin,
        UglifyJsPlugin,
    }
} = require('webpack')

// ...

new ContextReplacementPlugin(/moment[\/\\]locale$/, /en|vi|ja/),

The first argument is the context to which replacement is applied (in our case it is moment/locales directory), the second is limitation, defined by regular expression or function.

Moment.js optimized

Now only several locales are included in build.

Aliases

One of important features is possibility to alias module resolving. It might be required in case you need to shortcut your directories, or some change might be require to library.

Module aliases

Aliases are simply defined as map in resolve config group:

    resolve: {
        // ...
        alias: {
            handlebars: 'handlebars/dist/handlebars.min.js',
        },
        // ...
    },

This change was applied before. And the reason is that by default handlebars module is targeted on server-side environments. As our code is compiled in browser, there are incompatibilities, which are fixed in distributed script.

Such changes are quite handy and can save a lot of time.

Directory aliases

In most cases your project has some kind of structure and modules in deeper directories might require ones from upper levels. When such things happen, it is very complicated to track those dependencies, especially if you move files from one directory to another.

Navigating from some top-level folders is much simpler.

Let’s define the following aliases for our project:

const srcDir = resolve(__dirname, '..', 'src')

// ...

    resolve: {
        // ...
        alias: {
            handlebars: 'handlebars/dist/handlebars.min.js',

            '~html': resolve(srcDir, 'html'),
            '~styles': resolve(srcDir, 'styles'),
        },
        // ...
    },

After this change, you can simply use these aliases in any import or require statement.

import template from '~html/code.handlebars'

Strict match aliases

Sometimes, there might be issues with modules, but also, there are requirements from module subdirectories.

Highlight.js default

For example, our build includes a lot of languages supported by highlight.js library. But this issue cannot be fixed by context replacement, because there is no pattern matching in the source. All languages are imported directly.

highlight.js/lib/index.js

var hljs = require('./highlight');

hljs.registerLanguage('1c', require('./languages/1c'));
hljs.registerLanguage('abnf', require('./languages/abnf'));
hljs.registerLanguage('accesslog', require('./languages/accesslog'));
hljs.registerLanguage('actionscript', require('./languages/actionscript'));
// ...

This means, that we need to redefine this part of code somehow.

For this purpose we create our own highlight.js file with only required languages:

src/lib/highlight.js

import hljs from 'highlight.js/lib/highlight'

hljs.registerLanguage('javascript', require('./languages/javascript'))
hljs.registerLanguage('php', require('./languages/php'))

export default hljs

But what happens if we create an alias? There will be an error, because highlight.js will reference not only the module itself, but all subpaths of it.

And our imports both in rewritten module and in one of sources won’t work

import hljs from 'highlight.js'
// This becomes src/lib/highlight.js as expected
import 'highlight.js/styles/darkula.css'
// This becomes src/lib/highlight.js/styles/darkula.css

In this case, we define a strict match, which is made by simply adding $ character to alias definition:

    resolve: {
        // ...
        alias: {
            'highlight.js$': resolve(srcDir, 'lib', 'highlight.js'),
            // ...
        },
    },

This will be applied only to 'highlight.js' references, but any subpaths will still lead to initial module.

Now, we have decreased size of our bundle significantly.

Highlight.js optimized

What’s next?

In next article I will describe how to set up environment variables, and use source maps for development.

Repository with this tutorial sources available on GitHub


All Rights Reserved