Another way to use Webpack 4 with code splitting

Background

It's no secret that the code splitting strategy changed a lot with the release of Webpack 4. It would be fairer to say it was reinvented from scratch: the old approach simply stopped working, and it wasn't clear how to use the new one.

For those who still haven't heard, the webpack.optimize.CommonsChunkPlugin plugin is gone. Completely. Instead, you're supposed to write the following in the config:

module.exports = {
  // ...
  optimization: {
    splitChunks: {
      chunks: "all"
    }
  }
  // ...
}

This is supposed to work like magic. That is, we no longer tell webpack what to put into a common chunk; it does everything itself, possibly even better than we would.

And happiness will ensue. Just kidding. Not really... <cut />

Basic setup

Here's an example from the documentation:

module.exports = {
  mode: 'development',
  entry: {
    index: './src/index.js',
    another: './src/another-module.js'
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

The build produces 3 files: another.bundle.js, index.bundle.js, vendorsanotherindex.bundle.js

Hash: ac2ac6042ebb4f20ee54
Version: webpack 4.7.0
Time: 316ms
                          Asset      Size                 Chunks             Chunk Names
              another.bundle.js  5.95 KiB                another  [emitted]  another
                index.bundle.js  5.89 KiB                  index  [emitted]  index
vendors~another~index.bundle.js   547 KiB  vendors~another~index  [emitted]  vendors~another~index
Entrypoint index = vendors~another~index.bundle.js index.bundle.js
Entrypoint another = vendors~another~index.bundle.js another.bundle.js
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 489 bytes {vendors~another~index} [built]
[./node_modules/webpack/buildin/module.js] (webpack)/buildin/module.js 497 bytes {vendors~another~index} [built]
[./src/another-module.js] 88 bytes {another} [built]
[./src/index.js] 86 bytes {index} [built]
    + 1 hidden module

Now, to run our web applications, in one case we have to include vendorsanotherindex.bundle.js and index.bundle.js, and in the other vendorsanotherindex.bundle.js and another.bundle.js.

What's the problem?

The problem is the name vendorsanotherindex.bundle.js. As long as we have fewer than three entry points, nothing bad happens. Everything seems logical here: the bundle contains the npm modules (the vendors) and the modules shared by index and another. We include 2 files on each page and have no problems.

But with three or more entry points there can be many more new bundles (chunks), and we no longer know either how many there are or what they're called. Things get even more fun if we also extract CSS into separate files. And that's a problem.

How do we solve it?

When webpack finishes, we have no files that tell us which bundles need to be included on a given page, or in what order.

However, in the output we can find lines like these:

Entrypoint index = vendors~another~index.bundle.js index.bundle.js
Entrypoint another = vendors~another~index.bundle.js another.bundle.js

That's actually almost exactly what we need. Webpack knows perfectly well which bundles each entry point needs, but for some reason it doesn't want to share that information with us.

The manifest doesn't help here. Yes, we know that such a bundle (vendorsanotherindex.bundle.js) exists. We know where it lives. But we don't know who needs it. So the manifest is useless.

Then I figured that since webpack has the information, maybe it could be extracted with a plugin. I couldn't find a ready-made one, so I decided to write my own. And it's purely to show off this plugin that I'm writing this article.

import * as webpack from "webpack";

export interface IEntrypointsPluginOptions {
    readonly filename: string;
    readonly replacer?: (key: string, value: any) => any;
    readonly space?: string | number;
    readonly filter?: (chunk: webpack.compilation.Chunk) => boolean;
}

export default class EntrypointsPlugin {
    private readonly options: IEntrypointsPluginOptions;

    public constructor(options: IEntrypointsPluginOptions) {
        this.options = Object.assign<IEntrypointsPluginOptions, IEntrypointsPluginOptions>({
            filename: "entrypoints.json",
            replacer: null,
            space: null,
            filter: null
        }, options);
    }

    public apply(compiler: webpack.Compiler): void {
        compiler.hooks.emit.tap("entrypoints", (compilation: webpack.compilation.Compilation) => {
            const data = {};
            const filter = this.options.filter;
            const publicPath = compilation.compiler.options.output.publicPath;

            for (let [key, value] of compilation.entrypoints.entries()) {
                const chunks: webpack.compilation.Chunk[] = value.chunks.filter((data: webpack.compilation.Chunk) => {
                    return filter == null || filter(data);
                });

                const files = ([] as string[]).concat(...chunks.map(c => c.files.map(f => publicPath + f)));
                const js = files.filter(f => /\.js/.test(f) && !/\.js\.map/.test(f));
                const css = files.filter(f => /\.css/.test(f) && !/\.css\.map/.test(f));

                let entrypoint = {};
                if (js.length) entrypoint["js"] = js;
                if (css.length) entrypoint["css"] = css;

                data[key] = entrypoint;
            }
            const json = JSON.stringify(data, this.options.replacer, this.options.space);
            compilation.assets[this.options.filename] = {
                source: () => json,
                size: () => json.length
            };
        });
    }
}

Add the new plugin to webpack.config.(ts|js):

plugins: [
  new EntrypointsPlugin({
    filename: "entrypoints.json",
    space: 2
  })
]

and wait for the result. The result is an entrypoints.json file with content like this:

{
  "index": {
    "js": ["vendors~another~index.bundle.js", "index.bundle.js"]
  },
  "another": {
    "js": ["vendors~another~index.bundle.js", "another.bundle.js"]
  }
}

If extract-css is used, there will be a css section in addition to the js one.

The last thing left to do when building the HTML page is to read entrypoints.json, find the entry point you need, and include the js and css files from the corresponding lists.

Problem solved

Something like that.

Only registered users can leave a comment.

Sign in or register to leave a comment.