Tree-shaking with webpack 2 and Babel 6

[2015-12-20] dev, javascript, webpack, babel, jsmodules
(Ad, please don’t block)

Rich Harris’ module bundler Rollup popularized an important feature in the JavaScript world: tree-shaking, excluding unused exports from bundles. Rollup depends on the static structure of ES6 modules (imports and exports can’t be changed at runtime) to detect which exports are unused.

Tree-shaking for webpack is currently in beta. This blog post explains how it works. The project we are going to examine is on GitHub: tree-shaking-demo

How webpack 2 eliminates unused exports  

webpack 2, a new version that is in beta, eliminates unused exports in two steps:

  • First, all ES6 module files are combined into a single bundle file. In that file, exports that were not imported anywhere are not exported, anymore.

  • Second, the bundle is minified, while eliminating dead code. Therefore, entities that are neither exported nor used inside their modules do not appear in the minified bundle. Without the first step, dead code elimination would never remove exports (registering an export keeps it alive).

Unused exports can only be reliably detected at build time if the module system has a static structure. Therefore, webpack 2 can parse and understand all of ES6 and only tree-shakes if it detects an ES6 module. However, only imports and exports are transpiled to ES5. If you want all of the bundle to be in ES5, you need a transpiler for the remaining parts of ES6. In this blog post, we’ll use Babel 6.

Input: ES6 code  

The demo project has two ES6 modules.

helpers.js with helper functions:

// helpers.js
export function foo() {
    return 'foo';
}
export function bar() {
    return 'bar';
}

main.js, the entry point of the web application:

// main.js
import {foo} from './helpers';

let elem = document.getElementById('output');
elem.innerHTML = `Output: ${foo()}`;

Note that the export bar of module helpers is not used anywhere in this project.

Output without tree-shaking  

The canonical choice for Babel 6 is to use the preset es2015:

{
    presets: ['es2015'],
}

However, that preset includes the plugin transform-es2015-modules-commonjs, which means that Babel will output CommonJS modules and webpack won’t be able to tree-shake:

function(module, exports) {

	'use strict';

	Object.defineProperty(exports, "__esModule", {
	    value: true
	});
	exports.foo = foo;
	exports.bar = bar;
	function foo() {
	    return 'foo';
	}
	function bar() {
	    return 'bar';
	}

}

You can see that bar is part of the exports, which prevents it being recognized as dead code by minification.

Output with tree-shaking  

What we want is Babel’s es2015, but without the plugin transform-es2015-modules-commonjs. At the moment, the only way to get that is by mentioning all of the preset’s plugins in our configuration data, except for the one we want to exclude. The preset’s source is on GitHub, so it’s basically a case of copying and pasting:

{
    plugins: [
        'transform-es2015-template-literals',
        'transform-es2015-literals',
        'transform-es2015-function-name',
        'transform-es2015-arrow-functions',
        'transform-es2015-block-scoped-functions',
        'transform-es2015-classes',
        'transform-es2015-object-super',
        'transform-es2015-shorthand-properties',
        'transform-es2015-computed-properties',
        'transform-es2015-for-of',
        'transform-es2015-sticky-regex',
        'transform-es2015-unicode-regex',
        'check-es2015-constants',
        'transform-es2015-spread',
        'transform-es2015-parameters',
        'transform-es2015-destructuring',
        'transform-es2015-block-scoping',
        'transform-es2015-typeof-symbol',
        ['transform-regenerator', { async: false, asyncGenerators: false }],
    ],
}

If we build the project now, module helpers looks like this inside the bundle:

function(module, exports, __webpack_require__) {

	/* harmony export */ exports["foo"] = foo;
    /* unused harmony export bar */;

    function foo() {
	    return 'foo';
	}
	function bar() {
	    return 'bar';
	}
}

Only foo is an export now, but bar is still there. After minification, helpers looks like this (I’ve added line breaks and whitespace to make the code easier to read):

function (t, n, r) {
    function e() {
        return "foo"
    }

    n.foo = e
}

Et voilà – no more function bar!

Further reading