How to use native ES modules

How to use native ES modules

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.

table with browser's support for ES modules

source

Coding without ES modules

Before we had ES modules, all JS had to be imported globally. Each file could access variables & functions that were set up in the files imported earlier. The order of imports mattered, especially because in the case of name collisions, the things imported later could override what was imported earlier. 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>

index.html imports first display-data.js and then log.js

The example in action.

Problems

There are two main issues with this approach:

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

    This code live. 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.

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

ES modules in action

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:

  1. adding type=”module” to <script> import in the HTML file.
  2. using export and import keywords in the JS files to define and load modules. index.html <- display-data.js <- data.js Running example.

Multiple files importing the same file

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>

index.html imports both display-data.js & log-data.js & each of them imports data.js

The example

Lazy load

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>

index.html imports dislay-data.js, which after delay imports data.js

Lazy load example

Does the ES module cover all we need in modern JS?

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:

  1. Import types other than JS. Some other files are in the pipeline JSON, but it will be a long time before we get it in the browser.
  2. Import third-party libraries in a Node.js-like way. You could copy files over during the build and import them from a location inside node_modules, but it feels much more complicated than just import “library”.
  3. There is no transpilation. Plenty of modern JS is written in other languages—for example, TypeScript. Even pure JS needs transpilation to support older browsers or use the most recent language features.

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.

Summary

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.