Webpack 3, Dynamic Imports, Code Splitting, and Long Term Caching — Made Easy

After fighting with an upgrade from Webpack 1.x to 3.x (for longer than I’d like to admit) it all suddenly clicked. Behold the mystery revealed:

Demo of a simple app with multiple routes dynamically loading. Note the 200 response after “Page Two” is edited but a 304 for the unchanged files.

 TL:DR sample app repo

Let’s go through these config files and demystify what is happening.

I always start with the package.json file when looking at a new codebase. This allows me to get an idea of the dependencies before I see them in the code.

// package.json

"dependencies": {
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-loadable": "^5.3.1", <-- dynamic imports for react
    "react-router-dom": "^4.2.2"
  },

react-loadable will do all the heavy lifting for dynamic importing in our app. It’s a small wrapper that reduces boilerplate and adds some handy features. So, instead of having to do this for each dynamic import:

class MyComponent extends React.Component {
  state = {
    Bar: null
  };

  componentWillMount() {
    import('./components/Bar').then(Bar => {
      this.setState({ Bar });
    });
  }

  render() {
    let {Bar} = this.state;
    if (!Bar) {
      return <div>Loading...</div>;
    } else {
      return <Bar/>;
    };
  }
}

We can now do:

import Loadable from 'react-loadable';

const LoadableBar = Loadable({
  loader: () => import('./components/Bar'),
  loading() {
    return <div>Loading...</div>
  }
});

class MyComponent extends React.Component {
  render() {
    return <LoadableBar/>;
  }
}

Ok, onto the devDependencies:

// package.json

"devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-env": "^1.6.1",
    "babel-preset-react": "^6.24.1",
    "babel-preset-stage-0": "^6.24.1", <-- needed for dynamic import
    "html-webpack-plugin": "^2.30.1",
    "webpack": "^3.10.0",
    "webpack-dev-server": "^2.9.7"
  }

The only package here out of the ordinary is babel-preset-stage-0. Dynamic imports are a stage-3 proposal. You could just use babel-preset-stage-3 if you want but babel-preset-stage-0 will include all of stage-1, stage-2, and stage-3. Configure this to your codebase/liking as needed. All you need is stage-3 for the dynamic import feature.


That wraps up package.json. Onto .babelrc:

// .babelrc

{
  "presets": [
    "react",
    ["env", {
      "targets": {
        "browsers": ["last 2 versions"]
      }
    }],
    "stage-0" <-- no big surprise here
  ],
  "comments": true
}

Make sure to include stage-0 in your babel presets. Something to note: There are several dynamic import plugins for webpack/babel floating around out there. With Webpack 3, you do not need them in this use case (frontend only react app). Hopefully someone can correct me if I am wrong about this.

Note the "comments": true setting. This will help us name our dynamic imported files using Webpack’s magic comments.


Now for the elephant in the room webpack.config.js :

// webpack.config.js

...

entry: {
    app: APP_DIR +'/index.js',
    vendor: Object.keys(package.dependencies)
},
output: {
    publicPath: '/',
    chunkFilename: '[name].[chunkhash].js',
    filename: '[name].[chunkhash].js'
  },

...

plugins: [
    ...
    new webpack.HashedModuleIdsPlugin(),
    new webpack.optimize.CommonsChunkPlugin({
      name:'vendor',
      filename: 'vendor.[chunkhash].js'
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name:'manifest'
    })
  ]

...

That’s all that was needed outside of a “normal” React webpack setup. Mind blown.

Here’s a little more detail on what’s going on in this config.

Define your entry split points. In our case we want a app.x.js and a vendor.x.js file:

entry: {
  app: APP_DIR +'/index.js',
  vendor: Object.keys(package.dependencies)
},

Use [chunkhash] for your file name hashes in the output:

output: {
  publicPath: '/',
  chunkFilename: '[name].[chunkhash].js',
  filename: '[name].[chunkhash].js'
},

In plugins, use:

new webpack.HashedModuleIdsPlugin()

then your typical CommonsChunkPlugin with the chunkhash again:

new webpack.optimize.CommonsChunkPlugin({
  name:'vendor',
  filename: 'vendor.[chunkhash].js'
}),

Lastly, but an easy to overlook detail:

new webpack.optimize.CommonsChunkPlugin({
  name:'manifest'
})

I am going to dive into why the manifest file is important right here. Feel free to skip to the next section for the React code.

Without the manifest chunk, Webpack will produce these files:

pageOne.58e60ef81ba97426a00d.js  679 bytes             
   home.b65cb7fd922ea7126e95.js  671 bytes       
    app.3c3fcb4d6bcba55f3bb7.js    3.44 kB           
 vendor.da901bd61614a0e9f2fe.js    1.25 MB      
                     index.html  397 bytes

You might think, “Awesome! It’s working!” You go about your business, make a change to the home component. Webpack builds your files. Then you get sad.

  home.5a5f308381fc5670d102.js  674 bytes <--new hash expected
vendor.c509e728faa4374eee45.js    1.25 MB <--new hash not expected
                    index.html  397 bytes

What gives? You didn’t change/add anything to your package.json . The Webpack docs briefly explain what’s going on here. That makes sense at a very high level but I wanted to know what is actually changing in the code to warrant generating a new hash for the vendors.x.js file.

If you look inside the generated vendor.x.js you will see:

/******/   script.src = __webpack_require__.p + "" + ({"0":"pageOne","1":"home","2":"app"}[chunkId]||chunkId) + "." + {"0":"58e60ef81ba97426a00d","1":"5a5f308381fc5670d102","2":"3c3fcb4d6bcba55f3bb7"}[chunkId] + ".js";

Ah ha! These are all of our [chunkhash] values. Since we changed the homecomponent, a new hash was generated and added to the “webpack boilerplate” which in turn gets tossed into our vendor.x.js file. Not exactly what we want to happen seeing how our vendor file will not change very often and is generally the largest file in our build.

Let’s run the same exercise with the a webpack manifest file.

 pageOne.58e60ef81ba97426a00d.js  679 bytes 
    home.5a5f308381fc5670d102.js  674 bytes
  vendor.db2436c7653388db768a.js    1.24 MB
     app.8e527bb7a890edfc1ef3.js    3.44 kB
manifest.2b89533b2a3dea66348d.js    5.96 kB

Make a change to home

   home.b65cb7fd922ea7126e95.js  671 bytes
manifest.c4a2e5b1ff1dcfbe35ae.js    5.96 kB

🎉 Only a new hash was generated for the changed home component and the manifest .

Let’s look inside the manifest file:

/******/   script.src = __webpack_require__.p + "" + ({"0":"pageOne","1":"home","2":"vendor","3":"app"}[chunkId]||chunkId) + "." + {"0":"58e60ef81ba97426a00d","1":"b65cb7fd922ea7126e95","2":"db2436c7653388db768a","3":"8e527bb7a890edfc1ef3"}[chunkId] + ".js";

Now our [chunkhash] values are in manifest vs. goofing up our vendor file. Search for this code inside your vendor file and you will see that it is gone. Feels good.


Finally, the React code. This is probably the coolest part. I can see so much potential with react-loadable.

A normal import would look like:

import Home from './home'

All we have to do to get that sweet dynamic importing is:

import Loadable from 'react-loadable'
import Loading from './loading'

const Home = Loadable({
  loader: () => import('./home' /* webpackChunkName: 'home' */),
  loading: Loading, <-- a "loading" comp required by react-loadable
})

That’s all there is to it. Wrap your import in react-loadable and use the new import() syntax. Notice the Webpack magic comments in action with /* webpackChunkName: 'your_component_name' */ . No need to settle for 0.db2436c7653388db768a.js for a file name.


This post appeared previously on Medium. Leave a comment/feedback on the original post. If you have any code issues, open an issue on the github repo instead of trying to debug in the comments here. Thanks for reading!

 

Geoff Miller

Geoff Miller is a staff software engineer at Signifyd and a functional programming enthusiast

Related Posts
-
News For Merchants

ECOMMERCE IS HARD.
OUR NEWSLETTER CAN HELP.

Popular Posts
-