Graduate Program KB

Javascript Bundling - Part 1/2

Overview

  1. What is a bundler?
  2. Why bundle?
  3. Module Systems: Addressing the challenges of bundling
  4. How does a bundler work?
  • The "Webpack" way
  • The "Rollup" way
  1. Conclusion

What is a bundler?

The Node runtime code is organized into modules. Node supports two types of modules systems:

  1. CommonJS modules
  2. ECMAScript modules.

Bundling is the process of combining the code from one or more modules into a single Javascript file that can be run in the browser.

Why bundle?

Some of the reasons we might want to bundle are code:

  1. Browsers don't support the module system (not entirely true these days)
  2. Load modules in dependency order
  3. Load related assets (images, stylesheets etc.) in dependency order

Question: What happens when the following document is loaded by the browser?

<html>
  <script src="foo.js"></script>
  <script src="bar.js"></script>
  <script src="baz.js"></script>
</html>

Answer: 3 separate round trips are required to load the document

Wouldn't it be better if we could combine the content for the 3 files into a single file?

<html>
  <script src="bundle.js"></script>
</html>

Module Systems: Addressing the challenges of bundling

Some of the challenges we need to overcome:

  1. Mainting the order of the files we are loading
  2. Preventing naming conflicts
  3. Removing unused files from the final bundle

Our lives would be a lot easier if we knew:

  1. Which files depended on each other
  2. What interfaces each file exposed
  3. Which of these exposed interfaces were in use, and by whom

Module systems were created as a declarative way to describe the relationship that exists between the various files in our application.

Let's consider two different module systems:

CommonJS

// CommonJS
const flimFlam = require('./flim-flam.js')
const snapSnap = {
  flex: 'on This yo',
  speshFunc: flimFlam,
}
module.exports = snapSnap

ES Modules

// ES Module
import flimFlam from './flim-flam.js'
const snapSnap = {
  flex: 'on This yo',
  speshFunc: flimFlam,
}
export default snapSnap

How does a bundler work?

  1. How can we link the content in each of our files?
  2. How can we encapsule the linked content into a single file or "bundle"?

Different approaches to bundling

//circle.js
const PI = 3.141
export default function area(radius) {
  return PI * radius * radius
}
//square.js
export default function area(side) {
  return side * side
}
//app.js
import calculateAreaOfSquare from './square.js'
import calculateAreaOfCircle from './circle.js'
console.log(
  `Area of a square with an edge of length 5 is ${calculateAreaOfSquare(5)}`,
)
console.log(
  `Area of a circle with an radius of length 5 is ${calculateAreaOfCircle(5)}`,
)

Webpack

const modules = {
  'circle.js': function (exports, require) {
    const PI = 3.141
    exports.default = function area(radius) {
      return PI * radius * radius
    }
  },
  'square.js': function (exports, require) {
    exports.default = function area(side) {
      return side * side
    }
  },
  'app.js': function (exports, require) {
    const squareArea = require('square.js').default
    const circleArea = require('circle.js').default
    console.log('Area of square: ', squareArea(5))
    console.log('Area of circle', circleArea(5))
  },
}
// This is the "runtime"
webpackStart({
  modules,
  entry: 'app.js',
})

webpackStart is responsible for defining two things:

  1. The require function. It is not the same as require from CommonJS.
  2. The module cache. If we call require on the same module, the module factory function only needs to be invoked once.

Once we've defined require, we then invoke it on the entry module.

//webpack bundle.js
function webpackStart({ modules, entry }) {
  const moduleCache = {}
  const require = (moduleName) => {
    // if in cache, return the cached version
    if (moduleCache[moduleName]) {
      return moduleCache[moduleName]
    }
    const exports = {}
    // this will prevent infinite "require" loop
    // caused by circular dependencies
    moduleCache[moduleName] = exports

    // "require"-ing the module,
    // exported items are assigned to the "exports" object
    modules[moduleName](exports, require)
    return moduleCache[moduleName]
  }

  // start the program
  require(entry)
}

Rollup

This is what the Rollup bundle looks like:

const PI = 3.141

function circle$area(radius) {
  return PI * radius * radius
}

function square$area(side) {
  return side * side
}

console.log('Area of square: ', square$area(5))
console.log('Area of circle', circle$area(5))

Things to note:

  1. Rollup bundle is markedly smaller than the Webpack bundle
  2. No module map, with all modules flattened into the bundle
  3. All the code from individual modules is now in the global scope

Questions we need to ask:

  1. How do we avoid namespace collisions if everything is declared in the global scope? (see: Dangers of eval when bundling with Rollup)
  2. How should we sort the code all of the individual modules? Order does matter! (see: Temporal Dead Zone)

Rollup seems like a much simpler way to go about things. However, there is one big drawback to the Rollup approach...

Circular dependencies:

Let's take a look at a slightly contrived example:

//shape.js
const circle = require('./circle')

module.exports.PI = 3.141

console.log(circle(5))
//circle.js
const PI = require('./shape')
const _PI = PI * 1
module.exports = function (radius) {
  return _PI * radius * radius
}

Here's one possible approach Rollup could take:

//rollup-bundle.js

// cirlce.js first
const _PI = PI * 1 // throws "ReferenceError: PI is not defined"
function circle$Area(radius) {
  return _PI * radius * radius
}

// shape.js later
const PI = 3.141
console.log(circle$Area(5))

Question: Is there a simple way to solve this problem? Answer: Not really! Though, we could ask our users to not create circular dependencies in the first place...

One way solve this problem is to lazily evaluate PI:

//circle.js
const PI = require('./shape')
const _PI = () => PI * 1 // PI will be lazily evaluated
module.exports = function (radius) {
  return _PI() * radius * radius
}

With this approach, the order of the modules is of no consequence, because at the time _PI is evaluated, PI has already been defined in the outer (global) scope.

//rollup-bundle.js
// cirlce.js first
const _PI = () => PI * 1
function circle$Area(radius) {
  return _PI() * radius * radius
}

// shape.js later
const PI = 3.141
console.log(circle$Area(5)) // prints 78.525

Conclusion

In summary, we've learned:

  1. Module bundlers help us safely consolidate multiple modules into a single module
  2. Different bundlers work differently e.g. Webpack vs Rollup
  3. The Webpack approach to bundling:
    • Uses a module map
    • Wraps the code from each module with a function
    • Uses a runtime function to bind the module definitions
  4. The Rollup approach to bundling:
    • Produces a smaller, flatter bundle
    • Does not wrap code from each module in a function
    • Sorts bundled code in dependency order
    • Cannot deal with circular dependencies

Further Reading:

  1. ES modules: A cartoon deep-dive