Is all JavaScript treated equal?
What is JavaScript? Is it one thing?
These seem like obvious questions, but when you start digging into the various ways that JavaScript can be written, or even supersets of it like TypeScript, the answer is not so clear.
This post is a brief writeup answering two questions:
- What is a JavaScript "module system" for?
- What are the different module systems available, and why would you use one vs. the other?
- CommonJS
- UMD
- AMD
- ES6 Modules
- Bundlers like Browserify, Webpack, Rollup?
This post is NOT a deep-dive into any of these concepts; just a "TLDR" highlighting the broad concepts. After all, most developers do not have to deal with many of the nuances between module systems thanks to build tools that allow us to write something like TypeScript and watch as it "magically" compiles down to ES6 compatible JavaScript, which most browsers like Chrome, running the V8 engine know how to interpret and run on whatever OS architecture you're using.
I'm not saying the concepts are not important; just suggesting that knowing every little nuance here won't yield a productivity boost that would entice someone to spend their entire afternoon learning it.
The OG JavaScript - Scripts
Consider two files, located in the same directory. When the button is clicked, the function renderDateString()
from main.js
will call dateToYear()
from date-utils.js
and will render the current year to the p
container in the HTML document.
Very contrived example, but you'll see why in a bit.
<!-- index.html -->
<html>
<head></head>
<body>
<button onclick="renderDateString()">Show current year</button>
<!-- Renders to this container -->
<p id="date-string-container"></p>
<script src="date-utils.js"></script>
<script src="main.js"></script>
</body>
</html>
// main.js file
function renderDateString() {
document.querySelector(
"#date-string-container"
).textContent = `The year is: ${dateToYear(new Date())}`;
}
// date-utils.js
function dateToYear(date) {
return date.getFullYear();
}
A basic .js
script file loads in the global scope and works like a charm for simple requirements like this. But once we build something a bit bigger, things get a bit complicated...
Why would we need a "modules" system?
Now, what happens if we add another script file that defines the same function as the first one?
<!-- index.html -->
<html>
<head></head>
<body>
<button onclick="renderDateString()">Show current year</button>
<!-- Renders to this container -->
<p id="date-string-container"></p>
<script src="date-utils.js"></script>
<script src="copycat-date-utils.js"></script>
<script src="main.js"></script>
</body>
</html>
// copycat-date-utils.js
function dateToYear(date) {
return "oops!";
}
Now, when you click the button on the page, it prints oops!
because we loaded the copycat-date-utils.js
script after the date-utils.js
script, which means the dateToYear
function has the wrong implementation!
As you can see, even with a small amount of code and files, dealing with JS script files can quickly become disorienting as everything is declared to the global scope and all sorts of collisions can happen. Not only that, but in the example above, we're already loading 3 separate script files from the server, which means 3 separate network requests. This can quickly get out of hand and cause performance issues.
In summary, using the "OG JavaScript" (in the form of script files) has limitations when it comes to scaling a project. More specifically, the challenges here include:
- No encapsulation (hiding implementation details)
- Hard to split into many files
- Difficult to reuse logic in the form of "packages" or "modules" (think NPM)
The legacy solution (some devs still use it today, but it has become less popular) to all this was generally known as the "Revealing Module Pattern", or the "JQuery Module Pattern" and it looked something like this:
// An IIFE that acts as a "module"
const AppModule = (function () {
function renderDateString() {
document.querySelector(
"#date-string-container"
).textContent = `The year is: ${dateToYear(new Date())}`;
}
function dateToYear(date) {
return date.getFullYear();
}
return {
render: renderDateString,
};
})();
There are two important things happening here.
- First, since we are invoking an IIFE, this means that we are encapsulating the "module" that we have created. There is no way that we run into the problem in our prior example where
dateToYear
is defined in two different scripts and we're not sure which one applies. Here, since we are dealing with an IIFE, we know that another file cannot re-implement the same function name because there is no name! - Second, we are encapsulating logic such as
dateToYear
within theAppModule
and only exposing to consumers of this module what we want (i.e.render
). In JavaScript terms, we're using a "closure" to hide things inside a function's scope.
In index.html
, we can now use it like so:
<html>
<head></head>
<body>
<button onclick="AppModule.render()">Show current year</button>
<script src="main.js"></script>
</body>
</html>
So by using the module pattern, we can somewhat solve the encapsulation problem, but what about the "multiple files" problem? You could either...
- Write everything in one big file (like we have)
- Write multiple files and import multiple files via
<script />
tags (lots of network requests here) - Write multiple files and use an external build tool to combine them into one
As you can see, without a module system, there is a tendency to ship a LOT of code per <script />
, which means we not only have to deal with competing global namespaces, but also have to deal with a bunch of library code that goes unused in our projects (imports are "all or nothing").
Of course, we have a small and contrived example above, but as you can imagine, once we write more code, all of these concerns become more relevant.
CommonJS to the rescue! (sort of?)
Disclaimer: My recollection and historical timeline here might be off. I'm not the person to talk to for historical sequences and original motivations. My goal is to explain what these various technologies are in the context of module systems and nothing more. That said, I'll do my best to document the historical sequence here!
Back in 2009, Node.js was a brand new thing. Alongside this crazy idea of bringing JavaScript server-side was the concept of a "module system" called CommonJS (originally named ServerJS), which aimed to make it easier to split JS logic into multiple "modules" (in this context, synonymous with "files" since there is a 1:1 relationship between module:file).
As far as I'm aware, CommonJS doesn't have an official specification; only implementations such as the Node.js implementation.
If you've read the first part of this post, the benefit of CommonJS was essentially taking the "Module Pattern" (IIFEs) and making that work for each JS file. With CommonJS, each file exposed two new concepts:
// require statements to import other modules
require("something");
// exports statement to export things from current module
module.exports = {
encapsulatedVariable: "with some value",
};
With this new system, you could import and export things from files and avoid all the issues of dealing with global scope name collisions, encapsulation, etc.
In other words, we get the following two superpowers:
- If you don't export it, it doesn't exist in the global scope
- You can import only what you need
All that is great, but we have a bit of a problem...
The require()
syntax is synchronous, which means it would be a bad idea for client-side code as you'd be blocking the main thread and preventing user interactivity while modules were loading. So in search of a client-side solution, let's look at Browserify and the AMD system.
Browserify + AMD
This gets a bit confusing, so let's just jump in.
- AMD is a specification implemented by various packages such as require.js
- Browserify is a module bundler (not a module system), which means it uses the CommonJS module system and then you run Browserify as a command line tool to "bundle up" all of your CommonJS-style files to a single file that can be used in a browser
<script />
tag.
In other words, with what we currently know, there are 2 routes we could go to bring the concept of "modules" to our client-side code.
We can take the Browserify approach...
- Write our code with CommonJS syntax
- Run Browserify as a command line tool
The benefit here is we can write all of our code with the same module syntax. The downside is that we now have a build step to run before deploying our code.
Or the require.js approach...
- Write our code with AMD syntax
- Import something
require.js
as the root<script />
in the browser, tell it the "entrypoint" of the app, and let it resolve all the imports/exports of AMD
The benefit here is we can avoid build steps while being able to use modules client-side and server-side. The downside (IMO) is an ugly looking syntax that most developers do not want to use:
// AMD syntax
define(["dep1", "dep2"], function (dep1, dep2) {
//Define the module value by returning a value.
return function () {};
});
ES6 Modules
At the time of writing, all major browsers now generally support ES6 (ECMAScript 2015) modules, and the latest versions of Node.js also have general support.
ES6 modules (IMO) have a much nicer syntax and can be written both server-side and client-side, a big win for developer experience:
// Imports
import { someFn } from "some-module";
import * as NamespacedModule from "another-module";
import defaultModule from "third-module";
// Exports
export const myVariable = 20;
export function namedFunction() {}
export default function () {}
At this point, we've covered many of the major module systems in JS, but in order to actually make use of them in the browser and avoid having to import hundreds of individual scripts in the correct order, we need a "module bundler".
The last step: "module bundlers"
We've already briefly talked about this with Browserify, but in general, the most popular bundlers today are:
- Webpack
- Rollup
- Parcel
The goal with all of these is to take a bunch of files that are using some sort of module system (or multiple types) and consolidate these files into a small number of "bundles" that can be included via <script src="bundle.js" />
in the browser.
Think of it like "minifying your imports".
To wrap up, I'll take the code we looked at earlier in this post and "bundle it" with Webpack (although Rollup/Parcel work too!). By default, Webpack knows how to resolve ES6 Modules as you'll see in the example.
Please note, at the small size of my example, it is NOT necessary to create a bundle. The idea here is to give a simple example of something you'd do at a larger scale. Let's get started.
Let's assume I have three files in my project:
<!-- index.html -->
<html>
<head></head>
<body>
<button onclick="AppModule.renderDateString()">Show current year</button>
<!-- Renders to this container -->
<p id="date-string-container"></p>
<!-- Need type="module" now here! -->
<script src="dist/bundle.js" type="module"></script>
</body>
</html>
// main.js file
import { dateToYear } from "./date-utils.js";
function renderDateString() {
document.querySelector(
"#date-string-container"
).textContent = `The year is: ${dateToYear(new Date())}`;
}
// Make our "app" available in global scope since it is not by default because of ES Modules
window.AppModule = {
renderDateString,
};
// date-utils.js
export function dateToYear(date) {
return date.getFullYear();
}
To "bundle" this into a single bundle.js
script, I need to install Webpack.
yarn add -D webpack webpack-cli
In package.json
, I'll add a build script:
{
"scripts": {
"build": "webpack"
}
}
And finally, I need to tell webpack how to build my project with a webpack.config.js
file. As a side note, the Webpack config file uses CommonJS module syntax.
// webpack.config.js
module.exports = {
// Here, I tell webpack where the "main" file that imports everything else is
entry: "./main.js",
output: {
path: path.resolve(__dirname, "dist"), // standard output naming convention
filename: "bundle.js", // this is the file we'll include in our <script /> tag client-side
},
};
And finally, we can run yarn build
, which will create the file /dist/bundle.js
, which will be read by index.html
in the <script src="dist/bundle.js"></script>
line! Here's the bundle that was created:
// prettier-ignore
(()=>{"use strict";window.AppModule={renderDateString:function(){var e;document.querySelector("#date-string-container").textContent=`The year is: ${e=new Date,e.getFullYear()}`}}})();
As you can see, the bundle invokes an IIFE just as we did when using the manual "Module pattern" with basic JS script files!
Conclusion
There's a lot more to talk about in regards to modules, bundlers, transpilers, and all the associated concepts, but here's our executive summary:
- Why do we need module systems?
- So we can scale projects across many files
- Reuse dependencies / cleaner code
- Avoid global scope issues
- What are the module systems and what are their differences?
- CommonJS and ES6 Modules are arguably the most commonly used today with a trend towards ES6 Modules
- AMD (and other systems not mentioned) don't have much popularity and IMO will die off completely as ES6 Module gain more native support in browsers
- Bundlers are run as "build steps" to "bundle" many files that use module systems into a single (or very few) JS script files that can be imported client-side