Back to Table of Contents

ESM: ECMAScript Modules

What CoPilot thinks a cat looks like

By Jesse Pence

Introduction

Although they may seem ubiquitous if you’re a new developer starting with React, ESM (ECMAScript Modules) have only recently become the standard. Migration has been a bit slow and painful, as the community had largely coalesced around CommonJS modules. Other than that, there are also AMD and UMD modules, but those will soon be lost to the sands of time as they fall into disuse.

Why Modules?

We can use functions and closure to make modules. A module is a function or object that presents an interface but that hides its state and implementation. By using functions to produce modules, we can almost completely eliminate our use of global variables, thereby mitigating one of JavaScript’s worst features. —Douglas Crockford, JavaScript: The Good Parts

The process of loading a web page does not lend itself to modularity. Due to the all-or-nothing nature of the request-response cycle, each page is loaded as a single unit. The browser parses the document line by line, and each time it sees an external resource like a script tag, it pauses the parsing of the document and loads that resource.

On slower connections, the latency of each request can add up, leading to significant delays. This is part of why HTTP 2 introduced multiplexing and server push. Using the defer and async attributes on script tags can also help to alleviate this issue. But, even with these improvements, it is still not ideal to load all the scripts at once. This is especially true for large applications with a lot of code.

As websites grew more dynamic and complex with the emergence of AJAX, it also became difficult to manage how these scripts interacted with each other. As we saw in the last chapter, even if you split your application into multiple files, you had to worry about when each file was loaded into the global scope. Loading files from multiple libraries required great care to ensure that the correct order was maintained, and if two libraries had a variable or function with the same name… you could run into some serious issues.

Due to the issues with scoping in JavaScript, the eventual emergence of the IIFE (Immediately Invoked Function Expression) was inevitable. An IIFE is a function that is executed as soon as it is defined. This allows the developer to create a local scope for the variables and functions defined within it. This was even more crucial before let and const were introduced in ES6 which allow for block scoping.

// Global scope
var cheese = 'cheddar';

(function() {
    // local scope
    var cheese = 'gouda';
    console.log(cheese); // gouda
})(); // note the extra parentheses

console.log(cheese); // cheddar

This pattern creates a local scope for each library through what is known as a closure, which prevents conflicts between libraries. This is known as the module pattern. This pattern was used to create libraries such as jQuery and Underscore.

However, this was still not ideal. It was verbose and it didn’t alleviate the need for the developer to manually curate the order of the scripts. Like I said, this could get really messy because you had to ensure that each library’s dependencies were already loaded before adding them to the application. And, this was even more difficult outside of the browser without the inherent top-down nature of a document— in Node.js, for example.

CJS, AMD, and UMD

Prior to ECMAScript 6, JavaScript did not have built-in modules. Therefore, the flexible syntax of the language was used to implement custom module systems within the language. Two popular ones are CommonJS (targeting the server side) [and] AMD (Asynchronous Module Definition, targeting the client side). —Dr. Axel Rauschmayer, JavaScript for impatient programmers (ES2022 edition)

As JavaScript moved server-side, developers such as Kevin Dangoor began to advocate for a standard module system. Node.js was the first major platform to adopt a version of this standard, and it was eventually even used in the browser via Browserify and Webpack.

This standard is known as CommonJS modules which rely on the require() function to load modules and the module object to export modules. The module object is just a plain javascript object in which you can add properties that you want to export. Or, you can replace the entire object with a different object or a function that you want to export.

So, let’s take a look at how this works. First, we’ll create a module that exports a function that grates cheese. Notice how this completely replaces the module.exports object with a function.

cheeseGrater.js

function cheeseGrater(cheese) {
    return cheese + ' is grated now';
}
module.exports = cheeseGrater;

Now, we can import this module into another file and use it. But, we don’t have to just export one thing from a module. We can export multiple things by adding them to the module.exports object.

cheeseGraterCollection.js

var cheeseGrater = require('./cheeseGrater');

function superCheeseGrater(cheese) {
    return cheeseGrater(cheese) + '... in a really impressive way!';
}

function tinyCheeseGrater(cheese) {
    return cheeseGrater(cheese) + '... in a really cute way!';
}

module.exports = {
    superCheeseGrater,
    tinyCheeseGrater
};

Now, we can import both of these modules into another file and use them.

index.js

var { superCheeseGrater, tinyCheeseGrater } = require('./cheeseGraterCollection');

console.log(superCheeseGrater('cheddar'));
// cheddar is grated now... in a really impressive way!
console.log(tinyCheeseGrater('gouda'));
// gouda is grated now... in a really cute way!

Unfortunately, browsers cannot understand this code without a bundler like WebPack. And, even if they could, it would still be problematic. The problem is that this code is synchronous. When a CJS module is required, the entire module code is loaded and executed immediately— blocking the main thread.

While this is fine for server-side code (which can be multi-threaded), it is not ideal for the browser which is single-threaded (although Web Workers were introduced to solve this problem). This is especially true when loading large files or on poor connections. Blocking the main thread can cause the browser to appear unresponsive and can even cause the browser to crash.

AMD modules were designed to solve this problem by introducing an asynchronous loading mechanism that enables modules to be loaded in parallel without blocking the main thread. In AMD, modules are defined with an appropriately named define() function that takes both an array of dependencies and a factory function that controls the module’s behavior. The dependencies are not actually loaded until the factory function is called which alleviates the inherent blocking behavior of CJS modules.

cheeseGrater.js

define(function() {
    function cheeseGrater(cheese) {
        return cheese + ' is grated now';
    }
    
    return cheeseGrater;
});

cheeseGraterCollection.js

define(['./cheeseGrater'], function(cheeseGrater) {
    function superCheeseGrater(cheese) {
        return cheeseGrater(cheese) + '... in a really impressive way!';
    }

    return superCheeseGrater;
});

But, these two standards are not compatible with each other. This led to the emergence of UMD modules, which are a combination of the two. UMD modules are designed to be compatible with both AMD and CommonJS module loaders.

This is done by checking for the presence of the define() function, which is used by AMD loaders, and the presence of the module object, which is used by CommonJS loaders. If neither of these is present, the module is loaded into the global scope. To do this, we bring back our old friend the IIFE.

(function(root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD. Register as an anonymous module.
    define(['./cheeseGrater'], factory);
  } else if (typeof exports === 'object') {
    // Node.js/CommonJS
    module.exports = factory(require('./cheeseGrater'));
  } else {
    // In the browser, use the global object
    root.superCheeseGrater = factory(root.cheeseGrater);
  }
}(this, function(cheeseGrater) {
  // 'this' is the global context ('Window' in browser) in non-AMD/CJS environments;
  // It is discarded if using AMD or CJS modules.
  // 'cheeseGrater' will be:
  // in an AMD environment: the module './cheeseGraterAMD'
  // in a Node.js/CommonJS environment: the module './cheeseGraterCJS'
  // in the browser: `root.cheeseGrater`
  function superCheeseGrater(cheese) {
      return cheeseGrater(cheese) + '... in a really impressive way!';
  }
  return superCheeseGrater;
}));

Oof. While it is a clever use of an IIFE, it is not very readable. But, ESM are here to save the day!

ECMAScript Modules

CommonJS modules work quite well and, in combination with NPM, have allowed the JavaScript community to start sharing code on a large scale. But they remain a bit of a duct-tape hack… This is why the JavaScript standard from 2015 introduces its own, different module system. —Marijn Haverbeke, Eloquent JavaScript

In 2014, TC39 (the committee that defines the JavaScript language) finalized work on a new module system. This new system was designed to be compatible with both the browser and Node.js, and to be asynchronous by default. This is done by using the import and export keywords.

export default function cheeseGrater(cheese) {
    return cheese + ' is grated now';
}

export function improvedCheeseGrater(cheese) {
    return cheeseGrater(cheese) + `... like really grated!    
    Wow! It's so small now!
    That is a really impressive cheese grater!
    `
}

export function reallyTinyCheeseGrater(cheese) {
    return cheeseGrater(cheese) + '... not much, though. Dang, this thing is impractical.'
}

and then importing them:

import cheeseGrater, { improvedCheeseGrater, reallyTinyCheeseGrater } from './cheeseGraterCollection';

console.log(cheeseGrater('cheddar')); // cheddar is grated now
console.log(reallyTinyCheeseGrater('gouda'));
// gouda is grated now... not much, though. Dang, this thing is impractical.

In my opinion, this is much cleaner than the previous standards, and much more flexible. Notice how you can export multiple things from a module while still using a default export? Although many consider default exports to be an anti-pattern, they are still the preferred format for some.

ESM are also asynchronous by default, and they can even be dynamically imported. This means that you can import modules on-demand which is especially useful for code-splitting. Code-splitting is exactly what it sounds like: splitting your code into multiple smaller chunks that can be loaded on-demand. Tree-shaking is a similar concept that involves removing unused code from your application. Both of these techniques can be used to improve the performance of your application by reducing the amount of code that needs to be loaded.

This is useful for applications that have a lot of code, or for applications that have a lot of pages. While previously this could only be achieved with a bundler like the previously mentioned Browserify, WebPack or Rollup, ESM allows us to do this natively in the browser.

1-silly-demos

Unlike in the last chapter where everything was loaded onto the browser at once through script tags, modules will allow us to specify which files get loaded for each page. Speaking of our application, let’s take a look at how we can use ESM to improve it.

Applying ESM to our App

Finally, looking at this app doesn’t make me puke. —Jesse Pence

So, bundling is a whole thing of its own, and we will discuss it in greater detail in chapter 7, but for now let’s just focus on how we can use ESM to improve our app. At this point, ESM is supported by all major browsers, so we can start using it right away. But, how? It’s simple. First, let’s clean up our index.html file.

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="/style.css" />
    <title>Tell me this isn't a SPA</title>
    <script src="/App.js" type="module"></script>
  </head>
<!-- The Rest of the Owl -->

Yep, you saw that right. That’s it. Just one script tag with the type="module" attribute. This tells the browser that this script is an ESM module. And, it has the defer attribute by default so we don’t need to worry about that anymore. Now, let’s take a look at our App.js file.

App.js

import Router from "./components/Router.js"
import { searchListener, urlSearchHandler } from "./features/search.js"
import themeListener from "./features/theme.js"
import updateCart from "./features/cart.js"

themeListener()

searchListener()

updateCart()

// Don't worry, we clean this up even more 
// in the next chapter...

let urlParams = new URLSearchParams(location.search)
if (urlParams.has("search")) {
  urlSearchHandler()
} else {
  Router(location.pathname)
}

It’s important to note that these include the file extensions. Unless you are using a bundler or you create an import map, you will need to specify the file extension when using ESM in the browser. We’ll be discussing both this and adjusting our server to use ESM in the aforementioned chapter 6.

For now, the Routes.js file:

components/Routes.js

export const Routes = [
  { path: "/", component: "home" },
  { path: "/about", component: "about" },
  { path: "/products", component: "Products" },
  { path: "/product", component: "ProductPage" },
  { path: "/cart", component: "cart" },
  { path: "/checkout", component: "checkout" },
]

export async function Route(path) {
  const route = Routes.find((route) => route.path === path)
  const component = await import(`../pages/${route.component}.js`)
  return component.default()
}

This is where the magic happens. Notice how we can simply await the import of a module? While our current structure does not allow us to do much code-splitting, at least this allows us to only load the current view template for each page. This is a huge improvement over the previous version of our app, where we were loading everything at once.

We also added a new page to the app: the checkout page! No e-commerce app would be complete without a checkout page, right? This page is pretty simple. It’s just a form that collects the user’s information and sends it with their cart to an imaginary server. We will be using the FormData API.

Forms are an essential part of the web, and I wanted to include it in our demo app— especially as we will be converting it to different frameworks in later articles. It’s interesting to see how each framework handles forms— the most primitive form of mutation on the web. I’m not going to include the code here as we will be covering forms in detail in another article. As always, the code is avaiable below.

2-our-app

Conclusion

So, our app is finally becoming modern! It’s split up into individual files, and we are using ESM to load them on-demand. While it has taken a while to coalesce, I think it’s pretty clear that ECMAScript Modules are superior to their predecessors in almost every way.

In the next couple of chapters, we’re going to jump INTO THE FUTURE as we start playing with some experimental web API’s. First up: modern client-side routing with the Navigation API. See you there!

Additional Resources

Table of Contents Comments View Source Code Next Page!