Skip to content

Instantly share code, notes, and snippets.

@timse
Created May 8, 2017 13:34
Show Gist options
  • Save timse/6b761e7061d1ec8eaeae5767780db00d to your computer and use it in GitHub Desktop.
Save timse/6b761e7061d1ec8eaeae5767780db00d to your computer and use it in GitHub Desktop.

Predictable long term caching with Webpack

There are lots of ideas on how to best get long term caching into webpack. Here is yet another one.

tl;dr;

  • Use NamedModuleIds
  • Use NamedChunkIds
  • Sprinkle a little bit of magic

But First things first. What stops a default webpack build from being long term cacheable?

Let's just take the walk. Lets set up a small app with webpack an run into all those problems, then try to fix them.

The basics

This is out webpack config:

// webpack.config.js
const path = require('path');
const webpack = require('webpack');

module.exports = {
    entry: {
        main: './src/foo',
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].[hash].js',
    },
};

and this is what we find in foo.js

// foo.js
import preact from 'preact';

console.log(preact.toString());

Building this gives us a webpack output along these lines:

                       Asset     Size  Chunks             Chunk Names
main.db3022283e4b37cce06b.js  23.6 kB       0  [emitted]  main
   [0] ./~/preact/dist/preact.js 20.5 kB {0} [built]
   [1] ./src/foo.js 61 bytes {0} [built]

so far so good.

Vendor chunks

The first thing we may want to do is pull preact out of our main entry file, as it will hopefully change less often as the rest of our app, therefore we add a the CommonsChunkPlugin to our webpack config.

// webpack.config.js
...
plugins: [
    new webpack.optimize.CommonsChunkPlugin({
        name: ['vendor']
    })
]
...

We build out app again and we get an output similar to that:

                         Asset       Size  Chunks             Chunk Names
  main.423221edd7eef26d646e.js  506 bytes       0  [emitted]  main
vendor.423221edd7eef26d646e.js    26.7 kB       1  [emitted]  vendor
   [0] ./~/preact/dist/preact.js 20.5 kB {1} [built]
   [1] ./src/foo.js 61 bytes {0} [built]
   [2] multi preact 28 bytes {1} [built]

Don't fall asleep. Bear with the fun is about to begin.

The correct hash

You may have noticed, we encountered our first issue here. The main and the vendor chunk are the same. Any change to the main file would now also invalidate our vendor chunk.

To fix this we must switch from hash to chunkhash in our filename:

// webpack.config.js
...
output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].[chunkhash].js',
},
...

Running our build again we now see two different hashes.

                         Asset       Size  Chunks             Chunk Names
  main.edc22f71759cbe5336ae.js  528 bytes       0  [emitted]  main
vendor.27f1230219fd2a606a54.js    26.7 kB       1  [emitted]  vendor
   [0] ./~/preact/dist/preact.js 20.5 kB {1} [built]
   [1] ./src/foo.js 83 bytes {0} [built]
   [2] multi preact 28 bytes {1} [built]

So far so good.

You have runtime issues

Changing anything in the main chunk should now keep vendor chunk untouched. Lets add a new bogus line to it:

// foo.js
...
console.log(preact.toString());
console.log("hello world");

Running the build again however shows us that everything is lost, yet again:

                         Asset       Size  Chunks             Chunk Names
  main.91022729b32987083f0d.js  506 bytes       0  [emitted]  main
vendor.0da51f051fcf235d7027.js    26.7 kB       1  [emitted]  vendor
   [0] ./~/preact/dist/preact.js 20.5 kB {1} [built]
   [1] ./src/foo.js 61 bytes {0} [built]
   [2] multi preact 28 bytes {1} [built]

But why? The problem here is in the detail:

As webpack behaves extracting preact out of the main-chunk also extracts the runtime of webpack into it. The runtime is the part of webpack that resolves modules at runtime and handles async loading and more. Looking into it, we see a reference to our main chunk in it:

// somewhere in the vendor.0da51f051fcf235d7027.js
...
chunkId + "." + {"0":"91022729b32987083f0d"}[chunkId]
...

Luckily we can fix this. If you add a CommonsChunkPlugin with the name of a chunk that does not exist as the name of an entry-point webpack will extract the runtime, create a chunk by that name and put the runtime in there. Sounds magic? Well yeah I guess?

// webpack.config.js
...
plugins: [
    ...
    new webpack.optimize.CommonsChunkPlugin({
        name: ['runtime']
    })
]

Swinging our magic wand and running our build again now yields this:

                          Asset       Size  Chunks             Chunk Names
 vendor.634878b098e5c599febd.js    20.7 kB       0  [emitted]  vendor
   main.d59c6ff3126e3943c563.js  538 bytes       1  [emitted]  main
runtime.25ce0c546aab71f8eac5.js    5.92 kB       2  [emitted]  runtime
   [0] ./~/preact/dist/preact.js 20.5 kB {0} [built]
   [1] ./src/foo.js 93 bytes {1} [built]
   [2] multi preact 28 bytes {0} [built]

Changing something in the main chunk now, will only change the runtime and the main chunk, the vendor chunk will remain untouched.

Adding more dependencies

However the story does not end. As our project grows we add more dependencies:

// foo.js
import bar from './bar';
...

We run our build once again expecting only our build we only expect the main and vendor chunk to change. But you guessed it, thats not what happens:

                          Asset       Size  Chunks             Chunk Names
   main.cec87b856171489c2719.js  811 bytes       0  [emitted]  main
 vendor.73db375ed475c718163f.js    20.7 kB       1  [emitted]  vendor
runtime.93b41beba42ebff23af0.js    5.92 kB       2  [emitted]  runtime
   [0] ./~/preact/dist/preact.js 20.5 kB {1} [built]
   [1] ./src/bar.js 23 bytes {0} [built]
   [2] ./src/foo.js 118 bytes {0} [built]
   [3] multi preact 28 bytes {1} [built]

Even though nothing in the vendor chunk changed, its hash changed yet again. The reason is once again a detail. Every chunk gets a numerical chunk id. These are given in order, and as the order can change with every new import added, the chunks ids may change with them.

Name your chunk

Enter NamedChunksPlugin. This is a recent add to the webpack source (2.4) and allows to have names rather than numbers for our chunks:

// weback.config.js
...
plugins: [
    new webpack.NamedChunksPlugin(),
    ...
...

This will use the unique chunk name instead of its id to identify a chunk.

We can run our build again with and without the addition of bar.js and see the vendor chunk hash stays the same. Well, except it doesn't. Looking into the two vendor chunks we see something like this:

// old vendor build
...
/***/ }),
/* 1 */,
/* 2 */
/***/ (function(module, exports, __webpack_require__) {
...
// vendor build with new import
...
/***/ }),
/* 1 */,
/* 2 */
/* 3 */
/***/ (function(module, exports, __webpack_require__) {
...

Name your modules - sorry no pun here :(

For some reason webpack adds the ids of all the modules that exist to our vendor chunk. Lucky enough there is yet another solution. Enter NamedModulesPlugin.

// webpack.config.js
...
plugins: [
    new NamedModulesPlugin(),
    ...
...

It does very much the same as our the chunk equivalent. Instead of using ids for our modules it uses a unique path to them.

Our builds now keep the vendor hash always the same:

without bar.js:

                          Asset       Size   Chunks             Chunk Names
   main.5f15e6808c8037c8bdbc.js  628 bytes     main  [emitted]  main
runtime.72ef2fc7d9df236c7f1c.js    5.94 kB  runtime  [emitted]  runtime
 vendor.73c86187abcdf9fd7b18.js    20.7 kB   vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
   [0] multi preact 28 bytes {vendor} [built]
[./src/foo.js] ./src/foo.js 121 bytes {main} [built]

with bar.js:

                          Asset       Size   Chunks             Chunk Names
   main.47b747115cd1c2c24b93.js  901 bytes     main  [emitted]  main
runtime.8d94ab27ee79b53aa9a2.js    5.94 kB  runtime  [emitted]  runtime
 vendor.73c86187abcdf9fd7b18.js    20.7 kB   vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
[./src/bar.js] ./src/bar.js 23 bytes {main} [built]
   [0] multi preact 28 bytes {vendor} [built]
[./src/foo.js] ./src/foo.js 118 bytes {main} [built]

Is that it? Please tell me thats it, its boring as hell and everything changes all the time.

Well, guess what? haha.

Love me some async

As our app grows it gets heavy. To prevent loading all the codes at once we break it up using some async split points. First we add one

// foo.js
import('./async-bar').then( a => console.log(a));
...
                          Asset       Size   Chunks             Chunk Names
      0.9110a255e8cbd547adc7.js  311 bytes        0  [emitted]
 ...

and shortly after another

// foo.js
import('./async-bar').then( a => console.log(a));
import('./async-baz').then( a => console.log(a));
...
                          Asset       Size   Chunks             Chunk Names
      0.612f1fa751a3287cf615.js  311 bytes        0  [emitted]
      1.9606a5eadde08d70c763.js  311 bytes        1  [emitted]
...

WTF WEBPACK. Why is my async chunk suddenly named differently? And why do they have numbered ids again? I thought cache invalidation was hard, but you invalidate everything all the time :(.

Well turns out that per default the NamedChunkPlugin only handles chunks that are have a name. That is not the case for our async chunks. Stupid lazy OSS devs, pff.

Lets fix this. The NamedChunksPlugin accepts one parameter, this is a function that gets the chunk and must return an id for it. Lets change our plugin to something like this:

// webpack.config.js
plugins: [
	...
    new webpack.NamedChunksPlugin((chunk) => {
        if (chunk.name) {
            return chunk.name;
        }
        return chunk.modules.map(m => path.relative(m.context, m.request)).join("_");
    }),
]

Running our build again, we can now add as many async chunks as we want. Previously added ones will stay untouched:

                               Asset       Size        Chunks             Chunk Names
async-bar.js.06896d922ee7bb8af159.js  324 bytes  async-bar.js  [emitted]
async-baz.js.fff77d118cec24487e5d.js  324 bytes  async-baz.js  [emitted]
        main.16bb304a9926477df558.js    1.18 kB          main  [emitted]  main
     runtime.8f75ffddb5ac5820d4a9.js    6.01 kB       runtime  [emitted]  runtime
      vendor.73c86187abcdf9fd7b18.js    20.7 kB        vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
[./src/async-bar.js] ./src/async-bar.js 41 bytes {async-bar.js} [built]
[./src/async-baz.js] ./src/async-baz.js 41 bytes {async-baz.js} [built]
[./src/bar.js] ./src/bar.js 23 bytes {main} [built]
   [0] multi preact 28 bytes {vendor} [built]
[./src/foo.js] ./src/foo.js 218 bytes {main} [built]

Sidenote: Feel free to change the aesthetics of these async chunk names to whatever. Me too lazy.

Okeeeeh is that it now?

Well how about NO

Legacy before it was hipster - external deps

There is this once thing that wont die and we have to support it. Also for some reason we still use it somewhere in our app. And as we dont want to load it twice we want to get it from the global context. But being good devs we also want to make the dependency explizit. So we define our jQuery as an external dependency.

// webpack.config.js

...
externals: {
    jquery: 'jQuery'
},
...

What could pissibly go wrong.

before:

                               Asset       Size        Chunks             Chunk Names
async-bar.js.06896d922ee7bb8af159.js  324 bytes  async-bar.js  [emitted]
async-baz.js.fff77d118cec24487e5d.js  324 bytes  async-baz.js  [emitted]
        main.e4704e563c227ee88d16.js    1.21 kB          main  [emitted]  main
     runtime.6add1d75139c2dd504b1.js    6.01 kB       runtime  [emitted]  runtime
      vendor.73c86187abcdf9fd7b18.js    20.7 kB        vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
[./src/async-bar.js] ./src/async-bar.js 41 bytes {async-bar.js} [built]
[./src/async-baz.js] ./src/async-baz.js 41 bytes {async-baz.js} [built]
[./src/bar.js] ./src/bar.js 23 bytes {main} [built]
   [0] multi preact 28 bytes {vendor} [built]
[./src/foo.js] ./src/foo.js 250 bytes {main} [built]

after:

async-bar.js.06896d922ee7bb8af159.js  324 bytes  async-bar.js  [emitted]
async-baz.js.fff77d118cec24487e5d.js  324 bytes  async-baz.js  [emitted]
        main.f8b6bbf7315113f5b167.js    1.48 kB          main  [emitted]  main
     runtime.5544c7f42d217300ca94.js    6.01 kB       runtime  [emitted]  runtime
      vendor.3614a776703bbb63977c.js    20.7 kB        vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
[./src/async-bar.js] ./src/async-bar.js 41 bytes {async-bar.js} [built]
[./src/async-baz.js] ./src/async-baz.js 41 bytes {async-baz.js} [built]
[./src/bar.js] ./src/bar.js 23 bytes {main} [built]
   [1] multi preact 28 bytes {vendor} [built]
[./src/foo.js] ./src/foo.js 247 bytes {main} [built]

err yeah, thanks jQuery for killing my vendor chunk? What are you... WHAT?

Well turns out just as for the chunks the NamedModulesPlugin also only works for normal modules. That being said, the external module stole the id 0 from the multi preact module. Lets fix this once and for all.

Give everyone a name

Besides the normal modules there are a bunch of other modules webpack uses. Not all of them have are covered by the NormalModulesPlugin however. Therefore we need to roll our own (Maybe I should make this a package?):

For now lets add this to our webpack config:

// webpack.config.js
plugins: [
...
    {
        apply(compiler) {
            compiler.plugin("compilation", (compilation) => {
                compilation.plugin("before-module-ids", (modules) => {
                    modules.forEach((module) => {
                        if (module.id !== null) {
                            return;
                        }

                        module.id = module.identifier();
                    });
                });
            });
        }
    }
]

This works pretty much like the NormalModulesPlugin itself, except it uses the module#identifier method for all modules that do not have an id at this point.

Running our build once more we do get a new vendor cache but this once is persitant no matter how many external modules you may add!

async-bar.js.06896d922ee7bb8af159.js  324 bytes  async-bar.js  [emitted]
async-baz.js.fff77d118cec24487e5d.js  324 bytes  async-baz.js  [emitted]
        main.8fad02c2ce285a92402a.js    1.52 kB          main  [emitted]  main
     runtime.a1ba8a5ef1cf9f245e87.js    6.01 kB       runtime  [emitted]  runtime
      vendor.12a52011916cc3cb208c.js    20.8 kB        vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
[./src/async-bar.js] ./src/async-bar.js 41 bytes {async-bar.js} [built]
[./src/async-baz.js] ./src/async-baz.js 41 bytes {async-baz.js} [built]
[./src/bar.js] ./src/bar.js 23 bytes {main} [built]
[./src/foo.js] ./src/foo.js 247 bytes {main} [built]
[multi preact] multi preact 28 bytes {vendor} [built]

As you can see the id of the multi preact switched from [0] to [multi preact].

So that's it? No. One last step!

Adding more entry points

The app grows further and we have a kind of second SPA. So we seperate this out into a new entry point and add it to our webpack config:

// webpack.config.js
...
entry: {
    main: './src/foo',
    other: './src/foo-two',
    vendor: ['preact']
},
...

its content is something like this:

// foo-two.js
import bar from './bar';
import preact from 'preact';
import('./async-baz').then( a => console.log(a));

console.log(preact.toString() + "hello world again and again");

Running the build makes us (╯°□°)╯︵ ┻━┻.

The build now yields this:

                               Asset       Size        Chunks             Chunk Names
async-bar.js.06896d922ee7bb8af159.js  324 bytes  async-bar.js  [emitted]
async-baz.js.fff77d118cec24487e5d.js  324 bytes  async-baz.js  [emitted]
        main.8407140a96b58ecd9a36.js    1.32 kB          main  [emitted]  main
       other.e86bb03c185f1e6bfc33.js  863 bytes         other  [emitted]  other
     runtime.da520a6c3b0dab33c4dc.js    6.05 kB       runtime  [emitted]  runtime
      vendor.c0d706bb45788764e72e.js      21 kB        vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
[./src/async-bar.js] ./src/async-bar.js 41 bytes {async-bar.js} [built]
[./src/async-baz.js] ./src/async-baz.js 41 bytes {async-baz.js} [built]
[./src/bar.js] ./src/bar.js 23 bytes {vendor} [built]
[./src/foo-two.js] ./src/foo-two.js 168 bytes {other} [built]
[./src/foo.js] ./src/foo.js 247 bytes {main} [built]
[multi preact] multi preact 28 bytes {vendor} [built]
    + 1 hidden modules

Everything changed. The vendor, the main file, everything, but why?

Turns out that by default our vendor chunk does not only take what we specify but by default also everything we use in both of our entry-points. While this sometimes might be desired to share our common used own rolled modules, these should not end up in the vendor chunk. Maybe like now, we dont even want this at all. So lets do a final fix. Adding minChunks: Infinity to our vendor commons chunk tells webpack we really only want what we specified in the entry:

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: Infinity
}),

And if we run our build again:

                               Asset       Size        Chunks             Chunk Names
async-bar.js.06896d922ee7bb8af159.js  324 bytes  async-bar.js  [emitted]
async-baz.js.fff77d118cec24487e5d.js  324 bytes  async-baz.js  [emitted]
        main.8fad02c2ce285a92402a.js    1.52 kB          main  [emitted]  main
       other.db28c745f0f45fa4d8ed.js    1.06 kB         other  [emitted]  other
     runtime.506eb2c73a3b28891b05.js    6.05 kB       runtime  [emitted]  runtime
      vendor.12a52011916cc3cb208c.js    20.8 kB        vendor  [emitted]  vendor
[./node_modules/preact/dist/preact.js] ./~/preact/dist/preact.js 20.5 kB {vendor} [built]
[./src/async-bar.js] ./src/async-bar.js 41 bytes {async-bar.js} [built]
[./src/async-baz.js] ./src/async-baz.js 41 bytes {async-baz.js} [built]
[./src/bar.js] ./src/bar.js 23 bytes {main} {other} [built]
[./src/foo-two.js] ./src/foo-two.js 168 bytes {other} [built]
[./src/foo.js] ./src/foo.js 247 bytes {main} [built]
[multi preact] multi preact 28 bytes {vendor} [built]

We have exactly what we want. Our final webpack config looks like this:

const path = require('path');
const webpack = require('webpack');

module.exports = {
    entry: {
        main: './src/foo',
        other: './src/foo-two',
        vendor: ['preact']
    },
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name].[chunkhash].js',
    },
    externals: {
        jquery: 'jQuery'
    },
    plugins: [
        new webpack.NamedModulesPlugin(),
        new webpack.NamedChunksPlugin((chunk) => {
            if (chunk.name) {
                return chunk.name;
            }
            return chunk.modules.map(m => path.relative(m.context, m.request)).join("_");
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: Infinity
        }),
        new webpack.optimize.CommonsChunkPlugin({
            name: 'runtime'
        }),
        {
            apply(compiler) {
                compiler.plugin("compilation", (compilation) => {
                    compilation.plugin("before-module-ids", (modules) => {
                        modules.forEach((module) => {
                            if (module.id !== null) {
                                return;
                            }

                            module.id = module.identifier();
                        });
                    });
                });
            }
        }
    ]
}

Thats it. At least as far as I was able to think of edge cases. I do hope this helps. If you still find querks, feel free to comment and I will try to find a solution for them.

Now go out into the world and enjoy. And stop using weird hashing plugins that do more harm than good. Please.

Also, if this read got weirder the further you got. Thats not you. Thats my alcohol level. Cheers mate!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment