In this article, we take a look at JavaScript Modules as per the ES2015 specification, we'll see how to use them in the browser today as well as in Node.js as well as discussing some of their limitations.
Throughout the article the term ESM refers to ECMAScript Modules. (In other JavaScript related material we can also frequently see this reference, the other ones being CJS (CommonJS), AMD (Asynchronous Module Definition) and UMD (Universal Module Definition).
Before we start the discussion on modules, let's pause for a moment to review the real purpose of modules at the first place. The best analogy to understand modules is just like how books have chapters to maintain a logical flow and a clear separation between actions; similarly, our code should have modules that are separated and broken up by logical actions.
Why separate them? Because this way we can work with smaller files, which are less prone to errors as well as allow much greater maintainability plus they also have self-contained functionality.
Furthermore, once we have modules, those modules can be shared across any project that we work on, and they can also achieve what other languages refer to as namespacing. Just think about a utility module that would implement some operations on matrices. Once we write that module, we can reuse its functions in any other project where we may need that same functionality.
There are quite a few popular patterns in JavaScript that achieve "module-like" functionality which is dated "pre-ES2015". These include using an IIFE (Immediately Invoked Function Expression), the Object Literal notation and the revealing module pattern amongst others.
The most referenced literature on this topic is Addy Osmani's Learning JavaScript Design Patterns. Have a look at this book to learn more about these patterns.
There are also some module loaders and bundlers including but not limited to SystemJS, Rollup, Webpack and RequireJS that work with CommonJS, AMD and UMD patterns - these do not form part of this article. Please note however that in order to achieve compatibility with older browsers (that do not implement the import
/ export
syntax just yet) would still need to utilise the bundlers to transpile code down to ES5.
At the time of writing this article, ESM is supported via an experimental flag in Node.js, and there are some additional requirements as well:
mjs
node --experimental-modules
Let's take a look at an example:
// math.mjs
const PI = 3.1415;
function add(...numbers) {
return numbers.reduce((acc, i) => acc + i);
}
export { PI, add };
// app.mjs
import { PI, add } from './math.mjs';
console.log(PI);
console.log(add(1, 2));
In the code samples above, we could have used default exports
as well.
When it comes to modules there are several ways that we can export
variables from one module and based on that there are also multiple ways we can use to import
them:
/** EXPORTS **/
// named export
export { PI, add };
// default export
export default function add() { ... };
/** IMPORTS **/
// named import
import { pi } from 'module';
// default improt
import add from 'module';
After discussing support in Node.js, let's take a look at how to utilise ESM in browsers. Depending on the vendor and the version it is clear that most "modern" browsers do support ESM.
To work with modules natively, we need to adhere to a few rules again:
mjs
extension<script>
tag with a type=module
attribute as wellWe can take the previously used two files, and add the "main file" to our HTML like so:
<!-- index.html -->
<script type="module" src="app.mjs"></script>
Opening the file in Chrome (61+) should reveal log messages in the DevTools console. That's all that we need to get modules working.
nomodule
attributeAnother interesting attribute is nomodule
which should be added as a separate <script>
element - think of it as a fallback:
<!-- index.html -->
<script type="module" src="app.mjs"></script>
<script nomodule src="bundle.js"></script>
Browsers that understand the type="module"
attribute will not execute the <script>
tag that has the nomodule
attribute - where the src
should be a bundled, transpiled JavaScript file.
Some readers may be wondering, why do we need the mjs
extension or why do we need to add the type="module"
attribute to the <script>
tag, why can't the execution environment understand modules as-is?
module
vs script
The answer to that question is because ES6 introduced a differentiation between scripts and modules and this creates certain contracts that must be fulfilled. For example, specific keywords are forbidden in modules, because they implicitly define use strict
, therefore it is a requirement to specify the mode before the actual JavaScript code is run. Think of it as telling JavaScript how to handle that file to execute it correctly.
Another interesting scenario occurs with the location of the module (that is, the location of the .mjs file). They must be specified as a full URL or a relative URL. "Bare" module specifiers are not supported at the time of writing this article.
The reason behind this approach is to allow the creation of custom module loaders in the future, which would enable developers to be very specific about defining how to load a module.
Last but not least let's also mention "dynamic import".
When we imported our modules above, we saw "static import" in action. The drawback of using static import is that all the modules need to be downloaded before the main code can be executed. This, may not be the desired scenario because what if we wanted to load modules on-demand when they are needed?
We can check that a module gets downloaded regardless whether it is being used or not:
<script type="module">
const btn = document.getElementById('clickMe');
import { add } from './math.mjs';
btn.addEventListener('click', () => {
console.log(add(1, 2));
});
</script>
<body>
<button id="clickMe">Click Me!</button>
</body>
As seen from the above code, there's no need to load the math.mjs
module since it's only being used when someone clicks the button.
Dynamic imports make dynamic module loading possible, which means that upon initial load of the application, math.mjs
will not be loaded. Here's how we can achieve dynamic module loading:
<script type="module">
const btn = document.getElementById('clickMe');
btn.addEventListener('click', async () => {
const moduleLocation = './math.mjs';
const { add } = await import(moduleLocation); -->
console.log(add(1, 2));
});
</script>
<body>
<button id="clickMe">Click Me!</button>
</body>
Have a look at this great article about Using JavaScript modules on the web to learn more.
In this article, we have reviewed the current state of ESM (ECMAScript Modules) in the context of both Node.js and the browsers. Modules are getting better and better support, and I encourage you to try them out and start writing modular JavaScript.