An Overview of ES2015 (ES6) Modules

This post is 4 years old. (Or older!) Code samples may not work, screenshots may be missing and links could be broken. Although some of the content may be relevant please take it with a pinch of salt.

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).

Modular code

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.

Before the time of ES2015

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.

ESM - Support in Node.js

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:

  • file extension must be set to mjs
  • script must be executed via 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';

ESM - Support in the browser

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:

  • just like for Node.js, files must have an mjs extension
  • files must be added via a <script> tag with a type=module attribute as well

We 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 attribute

Another 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.

Limitations and gotchas

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.

Location of the module

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.

Dynamic import

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.

Conclusion

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.