Marius Schulz
Marius Schulz
Front End Engineer

Bundling and Tree-Shaking with Rollup and ES2015 Modules

Browserify and Webpack are great tools, but there's a new kid on the block: Rollup, the "next-generation JavaScript module bundler". The idea is that you author your application using ECMAScript 2015 modules, which Rollup then efficiently bundles into a single file.

Rollup, the next-generation JavaScript module bundler

What makes Rollup interesting is that it doesn't add any overhead to the generated bundle. There are no wrapper functions for registering and loading modules. This way, the generated bundles will always be smaller than the ones generated by Browserify or Webpack.

Instead of emitting all modules within wrapper functions, Rollup determines the entire application's dependency graph, sorts the imports topologically, and emits imported members in this order. You can think of this process as concatenating your modules in the correct order.

#Bundling CommonJS Modules with Browserify and Webpack

Before we get to how Rollup bundles modules, let's first take a look at the bundles created by Browserify and Webpack. We're going to be using two simple modules as an example. Within math.js, we define and export a simple square function:

module.exports = {
  square: square,
};

function square(x) {
  return x * x;
}

Within index.js, we import math.js as math and call the square function on it:

var math = require("./math");

var squared = math.square(7);
console.log(squared);

Here's the bundle that Browserify created:

(function e(t, n, r) {
  function s(o, u) {
    if (!n[o]) {
      if (!t[o]) {
        var a = typeof require == "function" && require;
        if (!u && a) return a(o, !0);
        if (i) return i(o, !0);
        var f = new Error("Cannot find module '" + o + "'");
        throw ((f.code = "MODULE_NOT_FOUND"), f);
      }
      var l = (n[o] = { exports: {} });
      t[o][0].call(
        l.exports,
        function (e) {
          var n = t[o][1][e];
          return s(n ? n : e);
        },
        l,
        l.exports,
        e,
        t,
        n,
        r,
      );
    }
    return n[o].exports;
  }
  var i = typeof require == "function" && require;
  for (var o = 0; o < r.length; o++) s(r[o]);
  return s;
})(
  {
    1: [
      function (require, module, exports) {
        var math = require("./math");

        var squared = math.square(7);
        console.log(squared);
      },
      { "./math": 2 },
    ],
    2: [
      function (require, module, exports) {
        module.exports = {
          square: square,
        };

        function square(x) {
          return x * x;
        }
      },
      {},
    ],
  },
  {},
  [1],
);

And here's the resulting Webpack bundle:

/******/ (function (modules) {
  // webpackBootstrap
  /******/ // The module cache
  /******/ var installedModules = {}; // The require function

  /******/ /******/ function __webpack_require__(moduleId) {
    /******/ // Check if module is in cache
    /******/ if (installedModules[moduleId])
      /******/ return installedModules[moduleId].exports; // Create a new module (and put it into the cache)

    /******/ /******/ var module = (installedModules[moduleId] = {
      /******/ exports: {},
      /******/ id: moduleId,
      /******/ loaded: false,
      /******/
    }); // Execute the module function

    /******/ /******/ modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__,
    ); // Flag the module as loaded

    /******/ /******/ module.loaded = true; // Return the exports of the module

    /******/ /******/ return module.exports;
    /******/
  } // expose the modules object (__webpack_modules__)

  /******/ /******/ __webpack_require__.m = modules; // expose the module cache

  /******/ /******/ __webpack_require__.c = installedModules; // __webpack_public_path__

  /******/ /******/ __webpack_require__.p = ""; // Load entry module and return exports

  /******/ /******/ return __webpack_require__(0);
  /******/
})(
  /************************************************************************/
  /******/ [
    /* 0 */
    /***/ function (module, exports, __webpack_require__) {
      var math = __webpack_require__(1);

      var squared = math.square(7);
      console.log(squared);

      /***/
    },
    /* 1 */
    /***/ function (module, exports) {
      module.exports = {
        square: square,
      };

      function square(x) {
        return x * x;
      }

      /***/
    },
    /******/
  ],
);

That's a lot of boilerplate code. It gets a lot shorter when minified, to be fair, but the overhead is still there. Let's see how Rollup compares.

#Bundling ECMAScript 2015 Modules with Rollup

Since Rollup requires ECMAScript 2015 modules, we have to change our application a little bit. Here's the updated math.js module, using the new export keyword:

export function square(x) {
  return x * x;
}

And here's the updated index.js module, which imports the square function using an import declaration:

import { square } from "./math";

var squared = square(7);
console.log(squared);

Alright, time for the showdown. Here's the bundle that Rollup creates for us:

function square(x) {
  return x * x;
}

var squared = square(7);
console.log(squared);

This bundle is a lot shorter than the other two. Notice what Rollup did: The square function has been inlined into the code of the index.js module, and all import and export declarations are gone. Plain and simple.

Note that this not a primitive string concatenation of the module source code. Rollup parses the modules and automatically renames identifiers with conflicting names so that inlining imported members doesn't break your code.

#The Static Structure of ECMAScript 2015 Modules

Let's take a minute and think about how Rollup can safely determine which members are imported or exported from a module.

ECMAScript 2015 modules have a fully static structure. Import and export declarations must be placed at the top level of a module — that is, they cannot be nested within another statement. Most importantly, this restriction prevents you from conditionally loading modules within if-statements:

if (Math.random() < 0.5) {
  import foo from "bar"; // Not allowed!
}

Also, import and export declarations cannot contain any dynamic parts. The module specifier must be a hardcoded string literal that either represents a file path or a module name. Variables or expressions computed at runtime are not valid:

var moduleName = Math.random() < 0.5 ? "foo" : "bar";
import * as module from moduleName; // Not allowed!

Together, these two guarantees allow Rollup to statically analyze the entire application's dependency graph because all imports and exports are known at compile-time.

#Eliminating Unused Code with Tree-Shaking

Imagine that the math.js module is a library written by somebody else. Although you're typically not using 100% of the library's functionality, bundling tools like Browserify or Webpack generally include the entire library source code in the bundle. You wanted a banana, but what you got was a gorilla holding the banana and the entire jungle.

Rollup does things differently. It popularized the term tree-shaking, which refers to the notion of removing unused library code from the resulting bundle. Only those library parts that are used within your application — and the transitive dependencies of these parts, respectively — will be included in the bundle that Rollup generates.

Let's demonstrate this with a slightly extended math.js module. We now export two functions, square and cube, which both depend on a (non-exported) function pow:

function pow(a, b) {
  return Math.pow(a, b);
}

export function square(x) {
  return pow(x, 2);
}

export function cube(x) {
  return pow(x, 3);
}

Within index.js, we still only import the square function:

import { square } from "./math";

var squared = square(7);
console.log(squared);

Here's the bundle that Rollup generates:

function pow(a, b) {
  return Math.pow(a, b);
}

function square(x) {
  return pow(x, 2);
}

var squared = square(7);
console.log(squared);

The square function was included because we directly import and call it, and pow was included along with it because square calls pow internally. However, cube was not included because we didn't import it. We shook the dependency tree, so to speak.

I think that tree-shaking has a bright future ahead of it. Removing unused library code can lead to noticeably smaller bundle sizes, which is especially beneficial to JavaScript web applications. Only using a handful of the 100+ functions that Lodash offers? Great, only import those, then!