November 19, 2016

ES6 module chunking and deferred loading with Webpack 2

Webpack does a lot more than bundle source files into a distributable package. Its code splitting and dynamic loading capabilities make it a great tool for controlling the lifecycle of an application. This article shows a real-world example of code splitting and chunking to positively impact user experience.

Update Jan 30th 2017: Webpack 2 final has deprecated System.import in favor of import() which complies with the spec. This article has been updated to the latest syntax.

I was recently tasked with creating a widget for showcasing 3D products. The primary goal was to create something that could be dropped into any page with a few lines of code. All product pages will have this code, but it will only be active on subset of products. The widget will display a 3D room with some assets, such as a couch, ottoman, which are made up of OBJ, MTL and PNG files that would need to be bundled with the rest of the code at build time. The result is a single JavaScript file, that will be loaded via a <script> tag.

Embedding 3D Assets

Preparing the assets involved lower their poly count in Blender and compressing textures using TinyPNG. Webpack's url-loader took care of base64 encoding assets into one module that I could import and use across the application:

// file: models.js
import couchTexture from './models/couch.512.png';
import couchMTL from './models/couch.mtl';
import couchOBJ from './models/couch.obj';

export default {
    'couch.png': couchTexture,
    'couch.mtl': couchMTL,
    'couch.obj': couchOBJ
};
// file: webpack.config.dev.js
{
    test: /\.(png|mtl|obj)$/,
    loader: 'url-loader'
}

The resulting bundle came up to 537KB gzipped and minified.

Given the size of the bundle, it was important to leverage browser cache to store as much of the library as possible for a long time. The widget code wouldn't get updates as frequently as the 3D assets, so it made sense to split the assets into their own file. This way, the user will not have to re-download the rest of the library when a new 3D asset is added.

Code splitting and chunking

Webpack makes code splitting fairly straight-forward: instead of importing the module at the top of the file, import() is used at runtime.

Before:

// file: roomGenerator.js
import assets from './assets;

export function roomGenerator() {
    ...
    return Promise.resolve({ assets });
}

After:

// file: roomGenerator.js
export function roomGenerator() {
    ...
    return import('./assets')
                 .then(assets => { 
                          assets: assets.default 
                  });
}

During build time, Webpack splits assets.js into a separate bundle, and only downloads it when the import() call is invoked. In other words, the assets bundle can now be loaded conditionally, later or not at all.

  • room-widget.js - widget code, dependencies, renderer
  • room-widget.0.js - 3D assets

Dynamically Loading React Components

Next, I decided to move the renderer React component out of the initial chunk and dynamically load at run-time. In order for dynamically loaded React components to render properly, they need to be set on state:

// file: main.jsx
constructor(props) {
    super(props);
    if (this.props.product.has3Dmodel) {
       import('./renderer.jsx')
              .then(renderer => this.setState({ 
                  roomRenderer: renderer.default 
              }));
    }
}
...
render() {
    return (
            <this.state.roomRenderer
               room={this.state.room}
               zoom={this.props.zoom} />
           )
}

Passing props works the same way on a dynamically loaded React component, but on this.state.<component-name>.

Here are the resulting chunks:

  • room-widget.js - widget code, dependencies
  • room-widget.0.js - 3D assets
  • room-widget.1.js - 3D renderer

At this point only 25.3KB was needed on every page to determine whether to load the rest of the package. Not bad.

Dynamically loading third-party libraries

Next, I decided to split out the Mobile Detect dependency, since I only need it once I know the rest of the bundle is going to be loaded:

// file: main.jsx
...
constructor(props) {
    super(props);
    import('mobile-detect')
          .then((MobileDetect) => this.md = new MobileDetect())
...
}

Results:

After chunking and deferring loading dependencies and assets, I was able to reduce the initial chunk that is always downloaded to just under 10KB!

Webpack has given me and my team a better control of our applications' lifecycle, and our users benefit by having content delivered faster. What about your experience?

Note: Webpack 2 is currently in Beta, and documentation is still in progress, I've found this to be the most up-to-date. Features in this article are also available in Webpack 1, using ES5 syntax.