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
require
function. It is not the same asrequire
from CommonJS. - 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:
- 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