When building your UI library, you’ll need to choose whether to export your package as one of two different module types, CommonJS or ES Modules. In this lesson, we’ll discuss what these two module systems are, and how to choose between them.
As web applications become more complex, the amount of JavaScript we write increases. In the past, JavaScript was added to the page using <script>
tags, and in the context of the browser this has a few downsides:
Fortunately, there is a module system that’s compatible with web browsers, called ES Modules. A module system like ES Modules provide better encapsulation and isolation for individual JavaScript files. It does this by:
In a previous lesson, I briefly touched on the differences between CommonJS (CJS) and ES Modules (ESM).
I boiled the comparison to “ESM is natively supported in the browser, whereas CJS is not.” I then used that comparison to argue why we should opt to publish our package as ES Modules. However, there’s much more to the module story than this, so let’s dive deeper.
In the late 2000s, JavaScript was widely becoming a language used across the development stack, no longer limited to the browser. The JS community needed a module system to ensure that programs that grew beyond mere scripts were easy to manage and build.
CommonJS was created back in 2009 as a way to standardise the conventions of importing JavaScript on the server side. This was the module system that Node.js adopted and has been its default module system since.
While CommonJS works in server runtimes, it was never built into browsers. As such, if you want to use packages that use CommonJS, you’d need to use a build tool like Webpack to make your code web-compatible. The most common approach was to compile all the JavaScript required for your project into a single bundle that would get sent over to your consumers. There were a whole bunch of issues with this approach, but I won’t go further into them here.
If CommonJS is so.. well, common, why wasn’t it implemented in web browsers? There are a few significant design decisions baked into CommonJS that don’t make it a suitable module system for the web.
CommonJS modules are loaded synchronously
Imagine we’re writing an express web server. The entry file would look something like this:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello, World!');
});
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});
When running the application, our environment will come across the file and execute it. As it steps through the code, it will come across the require
function. Once it does, JavaScript looks for the file in the filesystem, loads it, and then executes it, repeating the process for any other require
functions it comes across. While this is happening the rest of the program is blocked from executing. Only when the module has been loaded, parsed, and executed, does the rest of the file continue executing.
Here’s another example of a program loading dependencies using CommonJS:
In the above, main.jsx
has two dependencies, react
and button.jsx
The main thing to note here is that the entire program is blocked until all the resources are resolved.
CommonJS is a dynamic module system
A CommonJS require path is resolved the moment JavaScript evaluates it, which gives it the huge benefit of allowing dynamic imports:
// a bunch of other logic
const getProdModule = (isProd) => {
const prodModulePath = './path/to/prod/module.js';
const devModulepath = './path/to/dev/module.js';
return require(isProd ? prodModulePath : devModulePath);
};
This gives developers a lot of flexibility to change the behaviour of the web application at run time, when the user is using the application.
While this looks like a very useful feature on the surface, it makes it very difficult to do some common web application optimisations. Because these modules are resolved dynamically, a build tool has no way of statically analysing your code ahead of time. As a result, it can’t determine which of your dependencies’ modules are left unused. Because of this, it can’t tree shake your code, which is the process of removing unused code from your bundle.
In the above image, the main.jsx
file uses some, but not all, of its dependencies’ exports. Because the bundler can’t statically analyse which exports are used, everything is bundled together. You end up with a bigger bundle size that includes code the end users will never need.
So if CommonJS doesn’t work for the web, what’s the alternative?
We talked about how loading scripts in the browsers was a blocking operation, with no module scope and no encapsulation. Developers handled this in different ways, either by using 3rd party module systems, or by using specific software design patterns to encapsulate their scripts. There was no built-in way to achieve this.
ES Modules were implemented in browser back in 2015 to resolve these problems, as well as the problems that CommonJS would present if used in the browser.
So far on this course, we’ve been writing code in the browser without the need for a bundler. This is because we’ve been using ES Modules which are well supported across the web.
The way ES Modules resolve dependencies is similar to CommonJS in that a file needs to explicitly import
its dependencies. There are a few fundamental differences in how ES Modules resolves dependencies when compared to CommonJS.
To understand the differences between ES Modules and CommonJS, let’s do a very very quick overview of how ES Modules work.
To start using a module in the browser, you’ll need to load in an entry file, typically this is found in the HTML and takes the following form:
<script type="module" src="./path-to-script.js"></script>
What distinguishes this module from a regular script is the type
attribute. This lets the browser know that it should treat this script as a module and not a regular script.
ES Modules are non-blocking
In the browser, ES Modules don’t block rendering when loading modules. Other scripts can still run along with any changes in the DOM. The browser can even load multiple modules at the same time. If this was not the case, then web sites with a lot of imports would be incredibly slow to load, as downloading many resources can take a lot of time.
A caveat with this approach is that you can find yourself loading multiple modules each with multiple dependencies could trigger a waterfall of network requests.
Here’s an example of how ES Modules can resolve multiple modules:
In the image above, you can see that the main.jsx
file has two dependencies, one on react
and another on Button.jsx
The main thing to note is that both React and Button.jsx can be fetched, parsed, and executed at the same time. While the rest of the main.jsx
is blocked from executing at this time, other modules of the web app can still run, and the end-user can still interact with the DOM.
You’ll often hear people say that ES Modules are asynchronous. I’ve found that using a blanket term like that can be ambiguous and misleading, because there is a degree of synchronicity when loading ES Modules. For instance, a module can’t finish being executed until all of its dependencies have been resolved.
ES Modules are a static module system
This means that ES Modules allow tools like bundlers to analyse the imports. As a result, it makes it easy to remove unused code when bundling.
This design feature brings with it a couple of limitations. These include:
import
statements must be at the top-level of a file, outside of any conditional blocksimport
path must not be dynamically generatedThis means that the following is not a valid import:
const isAdmin = user.isAdmin
import { userFunctions } = from `../users/${isAdmin ? 'adminFunctions' : 'userFunctions'}`
Let’s say you’re publishing a library with 30 exports and is 60kb in size. If a consumer uses only 2 of these exports, then their bundler can analyse the imports ahead of time and bundle only the used code. This means that when it comes to packaging their application, the bundler can omit the 28 unused exports, which could be a theoretical ~90% reduction in bundle size. This would make for a much speedier experience for your end users.
Lastly, ES Modules do have a way of importing modules dynamically, but it’s an opt-in feature and may require some additional work on behalf of the application developer to ensure that they work with bundling tools.
When developing a brand new library, you should always look to use ES Modules. Not only is it the system that’s natively supported in the browser, but it’s also supported in Node, and other server-side JavaScript runtimes.
Building libraries and applications using ES Modules mean that application developers have more control over things like bundle sizes, code splitting, and more.
If you export to CommonJS only then you force your consumers to add a build step in order to use our package. On the flip-side, if you export code as ES Modules, developers can consume your library without a build step. There are also ways to consume ES Modules in a CommonJS project, but diving into that is beyond the scope of this lesson.
Many JavaScript libraries offer both ES Modules and CommonJS versions of their library.
You can offer this too, but it will make your build tooling more complex. You can also choose to leave it up to your consumers to transform the packages they use to CommonJS should they need to.
With that said, if you expect your package to be used in older server runtimes, then you should consider exporting both.
To do this, you would need to create a bundle step that creates two different artefacts, one CommonJS file, and another ESM file.
You would then need to update your package.json to point to both files:
{
"name": "your-library",
"description": "Your library description",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
}
Understanding the different JavaScript module systems is an often-looked part of web development. Even if the outcome of this lesson is to “keep doing what we’re doing”, I hope that this deeper introduction to ES Modules helps you best utilise it in your future library and application projects.