Skip to main content

An Overview of ES2015 (ES6) Modules

5 min read

Older Article

This article was published 7 years ago. Some information may be outdated or no longer applicable.

JavaScript modules as per the ES2015 spec. How to wire them up in the browser, how to wire them up in Node.js, and where they fall short.

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

Think of modules the same way you’d think of chapters in a book. Each chapter covers one thing. Each module covers one thing.

Smaller files, fewer bugs, better maintainability. Each module carries its own self-contained functionality.

Once you’ve got modules, you can share them across projects. You can also get proper namespacing (something other languages have had for years). Write a matrix utility module once, reuse it everywhere.

Before the time of ES2015

Several popular patterns existed in JavaScript that bolted on “module-like” behaviour before ES2015 landed. IIFEs (Immediately Invoked Function Expressions), the Object Literal notation, and the revealing module pattern, among 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 module loaders and bundlers like SystemJS, Rollup, Webpack and RequireJS that work with CommonJS, AMD and UMD patterns. Those aren’t covered here. Worth noting though: to support older browsers that don’t understand the import / export syntax, you’d still need bundlers to transpile code down to ES5.

ESM - Support in Node.js

At the time of writing, ESM sits behind an experimental flag in Node.js, with a couple of extra requirements:

  • file extension must be set to mjs
  • script must be executed via node --experimental-modules

Here’s 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));

We could have used default exports here as well.

When it comes to modules there are several ways to export variables from one module, and based on that there are also multiple ways 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

Now for browser support. Depending on the vendor and the version, most “modern” browsers handle ESM just fine.

To work with modules natively, two rules apply:

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

Take those same two files from before, and wire up the “main file” in your HTML:

<!-- index.html -->
<script type="module" src="app.mjs"></script>

Open it in Chrome (61+) and you’ll see log messages in the DevTools console. That’s all it takes.

nomodule attribute

The nomodule attribute acts as a fallback. Bolt it onto a separate <script> element:

<!-- index.html -->
<script type="module" src="app.mjs"></script>
<script nomodule src="bundle.js"></script>

Browsers that understand type="module" will skip the <script> tag with nomodule. The src there should point to a bundled, transpiled JavaScript file.

Limitations and gotchas

You might be wondering: why the mjs extension? Why the type="module" attribute? Why can’t the execution environment just figure it out?

module vs script

ES6 introduced a differentiation between scripts and modules. This creates contracts that must be fulfilled. Modules implicitly define use strict, for instance, so certain keywords are forbidden. The mode has to be specified before the JavaScript code runs. Think of it as telling the engine how to handle that file.

Location of the module

The location of the .mjs file matters. You must use a full URL or a relative URL. “Bare” module specifiers don’t work at the time of writing.

The reasoning: this leaves room for custom module loaders in the future, letting developers define exactly how a module gets loaded.

Dynamic import

Static imports (what we’ve seen so far) have a drawback: every module gets downloaded before the main code can execute. That’s not always what you want.

We can verify that a module gets pulled down regardless of whether it’s actually used:

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

There’s no reason to load math.mjs upfront when it only fires on a button click.

Dynamic imports fix this. On initial load, math.mjs won’t be fetched at all:

<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

ESM (ECMAScript Modules) support keeps getting better across both Node.js and the browser. Try them out. Write modular JavaScript.