If you've done web development for any amount of time, there's a good chance you've had to work with tools like Webpack, Rollup, Browserify and other front-end tooling systems. But do you know why we started using them in the first place, and why they are still part of our everyday workflow?
For most of its early life, "the web" (ie. browsers) had only one way to load script files, via plain
This is now what we refer to as "classic" scripts. In classic scripts, all scope is shared (via the global
window object). There is also no explicit way to specify dependency between files, so order of
<script> tags matters. You need to be careful about the order in which files are imported, and be mindful of subsequent changes in them.
So what are modules anyway?
Why so many?
Eventually, module format spec was standardized by the TC39 committee, with ESM becoming the de-facto "future-proof" standard going forward. Along with CommonJS, it is the format you are most likely to come across and work with today.
Modules are loaded using keywords like
These are called module specifiers. They can be either full or relative URL's:
They can also be "bare" (ie. without specifying file extensions):
NOTE: the bare specifier is not yet supported in browsers as of the writing of this post, and requires tools to interpret the syntax.
The way modules are loaded is different between browsers and server environments (ie. Node).
Browser loads modules as stateful singletons - meaning that the same module will share its state anywhere it's imported in the application, for the duration of that session. A good mental model to help understand this better is:
"When you import a module, you're not getting a piece of code that you're running, you get a reference to an object in memory"
In a Node environment, however, modules are loaded as instances. This means that each imported module instance in your app has its own state, not shared with any of the other instances of the same module - so you no longer have a global module state.
By default, everything that is declared inside a module is scoped locally to that module. If you want to make something accessible outside the module (and really, that's the whole point), you need to
export it. There are several ways of exporting, and which one to use will depend on the target module format (ie. ESM vs CommonJS), as well as what kind of export you are doing.
In general, ES modules have two types of exports: default and named. You can export any top-level function, class or variable.
When to use which?
That really depends on the intended use and structure of the module. One approach is to
export default the main functionality of a module, to denote its main intent. Then any supplemental functionality or data are treated as named exports.
Format: ES6 Modules (ESM)
.mjs extensions (browser and Node, respectively), and utilizes
import keyword for importing, and
export for exporting.
When exporting from ESM, you can do either default or named exports:
Importing can be done using default imports:
or using named imports, where the name of the thing you're importing goes inside curly braces:
You can also import both default and named at the same time, as well as multiple named imports, using this one-line convention:
Some characteristics and rules of ESM:
import's are static and asynchronous (browsers are async in their nature, after all)
import's must live at the root level
import's also get hoisted, so can be written anywhere in the code
importspecifier can only accept a string literal (ie. it can't be dynamically generated)
"Tree-shaking" or what is also known as "dead code elimination" is a process through which tools like Webpack and Rollup do static analysis of your code, and try to determine which modules aren't utilized. Doing this allows these tools to drastically cut down on the overall bundle size, and only include what's actually needed to run your application or library code. ESM is much more compatible with this process.
To load an ESM module in a browser, we need to specify
"module" in the script tag:
When loading in Node, there are several ways to target ESM:
package.jsonof the project
- Use the
--input-type=moduleflag via Node CLI
Format: CommonJS (CJS)
CommonJS (or CJS) is a module system that originated in Node. It has been the prevalent format in the Node ecosystem for many years, and is still used in many libraries and tools today.
CJS modules are exported via
module.exports, and use the
require keyword for importing:
Note that the above can also be written this way:
To import CommonJS, use the
require keyword :
.cjs file extensions, and it cannot be loaded directly in a browser. For that exist specific tools that allow us to run CommonJS code in a browser environment, such as Browserify, Rollup and others.
Unlike ES6 Modules which are loaded asynchronously, CommonJS module resolution is synchronous. Remember how we talked about server environments having virtually no latency for module loading? This is why.
Due to this low latency, CJS modules are loaded dynamically, at runtime. The module specifier is a function, which can accept JS expressions, making
require dynamic. This allows us to do things like conditional imports:
With all the differences between the module formats we've seen so far, and the different runtime environment gotchas, it's evident why there is such a strong need for a single, standard module specification going into the future.
Now let us ask a question:
"What happens if you want to use a CommonJS module inside an ES6 module? Or the other way around?"
The short answer is, it depends. Browsers don't support this natively - so we have to yet again rely on our tooling to support this functionality.
Bundlers and transpilers such as Babel, Webpack, Rollup etc. have been doing a decent job of ensuring compatibility between the different module formats and runtimes, however some edge cases and issues still exist today.
We'll take a closer look at some of these interoperability concerns in a future post.
Before wrapping this up, let's consider something for a second. If there are so many issues with interoperability, then why are we still using CommonJS?
The answer to that is not a simple one, unfortunately. First, CommonJS is everywhere. It's still the main format used in Node, and has been for many years (Node only recently started supporting ESM). The NPM ecosystem is huge. And though many popular library authors have been shipping both ESM and CJS compatible code, there are still many packages out there that are exclusively CommonJS. This is especially true for legacy systems, and enterprise code, which may not have the luxury of moving its entire codebase to an entirely different module system.
Second, it's a lengthy process. Lots of large (and popular) codebases and tools still use and heavily rely on it. So while the goal may be to move to a single unified format down the road (ESM), it is not an overnight change, and will take some time for the entire ecosystem to migrate to it.
There are some folks who are eager to help move it forward, though others are against the idea altogether. Both sides present valuable arguments, and the discussions are worth checking out if you're interested in the topic.
In a future post, we'll take a look at how to ship your library code in both ESM and CommonJS using Rollup.
Until next time!