Javascript Bundling - Part 1/2
Overview
- What is a bundler?
- Why bundle?
- Module Systems: Addressing the challenges of bundling
- How does a bundler work?
- The "Webpack" way
- The "Rollup" way
- Conclusion
What is a bundler?
The Node runtime code is organized into modules. Node supports two types of modules systems:
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:
- Browsers don't support the module system (not entirely true these days)
- Load modules in dependency order
- 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:
- Mainting the order of the files we are loading
- Preventing naming conflicts
- Removing unused files from the final bundle
Our lives would be a lot easier if we knew:
- Which files depended on each other
- What interfaces each file exposed
- 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?
- How can we link the content in each of our files?
- 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:
- The
requirefunction. It is not the same asrequirefrom CommonJS. - The module cache. If we call
requireon 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:
- Rollup bundle is markedly smaller than the Webpack bundle
- No module map, with all modules flattened into the bundle
- All the code from individual modules is now in the global scope
Questions we need to ask:
- How do we avoid namespace collisions if everything is declared in the global scope? (see: Dangers of eval when bundling with Rollup)
- 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:
- Module bundlers help us safely consolidate multiple modules into a single module
- Different bundlers work differently e.g. Webpack vs Rollup
- 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
- 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