This article will present examples of ECMAScript (ES) modules—what you can achieve with them and where you will hit some limitations. All browsers released after May 2018 support ES modules, so you can assume they are safe to use in most cases.
Before we had ES modules, all JS had to be imported globally. Each file could access variables previously defined and leave stuff for the code executed later. The order of imports mattered, especially because things imported later could override previous values. Old-school imports in action looked like the following:
display-data.js
:
document.body.innerHTML = "lorem ipsum";
log.js
:
console.log("Some test info");
index.html
:
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>No modules</title>
<link rel="shortcut icon" href="#" />
</head>
<body>
<script src="./display-data.js"></script>
<script src="./log.js"></script>
</body>
</html>
There are two main issues with this approach:
It pollutes the global scope. If you have a few files defining the same value, then they will collide and override each other. Good luck finding and fixing the bugs it can cause. Example:
data-1.js
:
var data = “lorem ipsum”;
data-2.js
:
var data = “sin dolor”;
index.html
:
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>Name collision</title>
<link rel="shortcut icon" href="#" />
</head>
<body>
<script src="./data-1.js"></script>
<script src="./data-2.js"></script>
<script>
document.body.innerHTML = data;
</script>
</body>
</html>
The most common workaround was using an immediately invoked function expression. This isolated blocks of code and prevented global scope pollution, but at the same time, it made the code more confusing.
Any dependency had to be managed and resolved manually. If you had one file depending on another, then you had to make sure to import those files in the correct order. For example:
log-data.js
:
console.log(data);
data.js
:
const data = ‘some data’;
display-data.js
:
document.html = data;
index.html
:
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>File order</title>
<link rel="shortcut icon" href="#" />
</head>
<body>
<script src="./log-data.js"></script>
<script src="./data.js"></script>
<script src="./display-data.js"></script>
</body>
</html>
As you can see here, the display data part works as expected, whereas logging data fails.
What is the difference if we do the same thing with ES modules? First of all, you define the dependencies on the code level. So if in one file you want values from another, you just specify it in the same file. This approach makes a difference, especially in reading code: you just need to open one file to get the idea of all the context it’s using just by reading it.
So how do we use the ES modules?
data.js
:
export const data = "lorem ipsum";
display-data.js
:
import { data } from "./data.js";
document.body.innerHTML = data;
index.html
:
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>Simple modules</title>
<link rel="shortcut icon" href="#" />
</head>
<body>
<script type="module" src="./display-data.js"></script>
</body>
</html>
The main changes in this code:
adding type=”module”
to <script>
import in the HTML file.
using export and import keywords in the JS files to define and load modules.
We can make our example more interesting by importing the same files twice. Because we need each file to be independent of the other, the import will be added twice—in each file separately. The browsers manage the import correctly and load the file only once.
data.js
:
export const data = "lorem ipsum";
display-data.js
:
import { data } from "./data.js";
document.body.innerHTML = data;
log-data.js
:
import { data } from "./data.js";
console.log(data);
index.html
:
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>Shared import</title>
<link rel="shortcut icon" href="#" />
</head>
<body>
<script type="module" src="./display-data.js"></script>
<script type="module" src="./log-data.js"></script>
</body>
</html>
Lazy load delays the loading part of the application until the code is necessary. This is a more complicated optimization technique than loading everything at once, but it enables more control over what is loaded when. In the example below, I load and display data after a delay of half a second:
display-data.js
:
setTimeout(
() =>
import("./data.js").then(({ data }) => {
document.body.innerHTML = data;
}),
500
);
data.js
:
export const data = "lorem ipsum";
index.html
:
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>Lazy load</title>
<link rel="shortcut icon" href="#" />
</head>
<body>
<script type="module" src="./display-data.js"></script>
</body>
</html>
Although native ES modules significantly improve previous models of including stuff, they lack a few essential features for modern JavaScript development. Right now, you cannot do the following:
node_modules
, but it feels much more complicated than just import “library”
.Because of these reasons, in most projects, you see JS bundlers, a kind of compiler that prepares the build for the deployments. If you are interested in bundlers, let me know in the comments and check out the links.
In this post, we walked through critical use cases of ES modules. The next step would be to set up some JS bundler to go over the limitations of the native modules.
First Published here