All I need to know about ECMAScript modules
What is an ES modules?
An ECMAScript module (ES module for short) is a mechanism for code reuse in JavaScript, introduced in 2015. It's finally becoming the standard in the highly fragmented JavaScript module scene.
Up until 2015 JavaScript didn't have a standard mechanism for code reuse. There had been a lot of attempts to standardize this aspect, which lead to messy fragmentation during the years.
You might have heard about AMD modules, UMD, or CommonJS. There was no clear winner. Finally, with ECMAScript 2015, ES modules landed in the language.
We now have an "official" module system.
ECMAScript modules everywhere?
In theory, ECMAScript modules should work universally across all JavaScript environments. In reality, browsers remain the main target for ES modules.
In May 2020, Node.js v12.17.0 shipped with support for ECMAScript modules without a flag. That means we can now use import
and export
in Node.js without any additional command line flag.
There is still a long way to go before ECMAScript modules will work universally across any JavaScript environment, but the direction is right.
How does an ES module looks like?
An ES module is a simple file where we can declare one or more exports. Take this fictional utils.js
:
// utils.js
export function funcA() {
return "Hello named export!";
}
export default function funcB() {
return "Hello default export!";
}
We have two exports here.
The first one is a named export, followed by a default export, denoted as export default
.
Assuming we have this file named utils.js
living in our project folder, we can import the objects offered by this module in another file.
How to import from ES modules
Suppose we have another file named consumer.js
in our project folder. To import the function exposed by utils.js
we can do:
// consumer.js
import { funcA } from "./util.js";
This syntax is a named import specular to the named export.
To import instead funcB
which is defined as default export we can do:
// consumer.js
import funcB from "./util.js";
In case we want to import both the default, and the named export in a single file we can condense to:
// consumer.js
import funcB, { funcA } from "./util.js";
funcB();
funcA();
We can also import a whole module with the star:
import * as myModule from "./util.js";
myModule.funcA();
myModule.default();
Be aware, in this case the default export must be called explicitly.
To import from a remote module:
import { createStore } from "https://unpkg.com/redux@4.0.5/es/redux.mjs";
const store = createStore(/* do stuff */)
ECMAScript modules in the browser
Modern browsers support ES modules, although with some caveats. To load a module, add module
to the type
attribute of a script tag:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ECMAScript modules in the browser</title>
</head>
<body>
<p id="el">The result is: </p>
</body>
<script type="module">
import { appendResult } from "./myModule.js";
const el = document.getElementById("el");
appendResult(el);
</script>
</html>
Here myModule.js
is a simple module in the same project folder:
export function appendResult(element) {
const result = Math.random();
element.innerText += result;
}
While it's possible to use ES modules directly in the browser, nowadays the task of bundling JavaScript apps is still exclusive for tools like webpack, for maximum flexibility, code splitting, and compatibility with older browsers.
Dynamic imports
ES modules are static, meaning we cannot change imports at runtime. With dynamic imports, landed in 2020, we can dynamically load our code in response to user interactions (webpack offered dynamic imports long before this feature shipped in ECMAScript 2020).
Consider a simple HTML which loads a script:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dynamic imports</title>
</head>
<body>
<button id="btn">Load!</button>
</body>
<script src="loader.js"></script>
</html>
Consider also a JavaScript module with a couple of exports:
// util.js
export function funcA() {
console.log("Hello named export!");
}
export default function funcB() {
console.log("Hello default export!");
}
To load this module dynamically, maybe on a click, we can do:
// loader.js
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
// loads named export
import("./util.js").then(({ funcA }) => {
funcA();
});
});
Here we load just the named export by destructuring the module's object:
({ funcA }) => {}
ES modules in fact are JavaScript objects: we can destructure their properties as well as call any of their exposed methods.
To dynamically import a default export instead we can do:
// loader.js
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
// loads entire module
// runs default export
import("./util.js").then((module) => {
module.default();
});
});
When importing a module as a whole we can use all of its exports:
// loader.js
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
// loads entire module
// uses everything
import("./util.js").then((module) => {
module.funcA();
module.default();
});
});
There's also another common style for dynamic import where we extract the logic at the top of the file:
const loadUtil = () => import("./util.js");
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
//
});
Here loadUtil
will return a Promise, ready for chaining:
const loadUtil = () => import("./util.js");
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
loadUtil().then(module => {
module.funcA();
module.default();
});
});
Dynamic imports look nice, but what they're good for?
With dynamic import we can split our code, and load only what matters at the right moment. Before dynamic import landed in JavaScript, this pattern was an exclusive for webpack, the module bundler.
Frontend libraries like React and Vue make large use of code splitting via dynamic imports to load chunk of codes on response to events, like user interactions or route changes.
Dynamic import of a JSON file
Suppose you have a JSON file somewhere in the codebase in person.json
:
{
"name": "Jules",
"age": 43
}
Now, you want to import this file dynamically in response to some user interaction.
Since JSON files exports just a default export, which is not a function, you can access the default export only like this:
const loadPerson = () => import("./person.json");
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
loadPerson().then(module => {
const { name, age } = module.default;
console.log(name, age);
});
});
Here we destructure name
and age
from the default export:
const { name, age } = module.default;
Dynamic imports with async/await
The import()
statement returns always a Promise, meaning we can use async/await
on it:
const loadUtil = () => import("./util.js");
const btn = document.getElementById("btn");
btn.addEventListener("click", async () => {
const utilsModule = await loadUtil();
utilsModule.funcA();
utilsModule.default();
});
Dynamic import names
When importing a module with import()
you can name it as you wish, just be consistent:
import("./util.js").then((module) => {
module.funcA();
module.default();
});
Or:
import("./util.js").then((utilModule) => {
utilModule.funcA();
utilModule.default();
});