Carl Rippon

Building SPAs

Carl Rippon
BlogBooks / CoursesAbout
This site uses cookies. Click here to find out more

JavaScript Modules in 2020

August 19, 2020
javascript

In this post, we will cover standard JavaScript modules, how we generally use them today in frontend apps, and how we may be using them in the future. JavaScript modules are sometimes referred to as ESM, which stands for ECMAScript modules.

JavaScript Modules in 2020

What are JavaScript modules?

JavaScript modules are a way to structure JavaScript code. Code in a module is isolated from code in other modules and is not in the global scope.

<script>
  function hello() {
    console.log("hello Bob");
  }
</script>
<script>
  function hello() {
    console.log("hello Fred");
  }
</script>
<script>
  hello(); // outputs hello Fred
</script>

The above code demonstrates two functions, not using modules, in the global scope colliding.

The other problem that JavaScript modules solves is not having to worry about the ordering of script elements on a HTML page:

<script>
  hello(); // 💥 - Uncaught ReferenceError: hello is not defined
</script>
<script>
  function hello() {
    console.log("hello");
  }
</script>

In the above example, the script element that contains the hello function needs to placed before the script element that invokes hello for it to work. This is hard to manage if there are lots of JavaScript files.

How JavaScript modules are commonly used today

The JavaScript module syntax was introduced in ES6 and is commonly used in apps we build today as follows:

import React from 'react';
...
export const HomePage = () => ...

The above example imports the React module and exports a HomePage component.

This code isn’t using JavaScript modules natively though. Instead, Webpack transpiles this into non-native modules (IIFEs). It’s worth noting that Webpack does have an experimental outputModule feature that allows it to publish to native module format. Hopefully this will be included in Webpack 5!

Using JavaScript modules natively

To declare a script element that references code from a JavaScript module, it needs a type attribute set to "module":

<script type="module">
  import { hello } from "/src/a.js";
  hello();
</script>

Here’s the JavaScript from a.js, in the src folder:

// /src/a.js
import { hellob } from "/src/b.js";
import { helloc } from "/src/c.js";

export function hello() {
  hellob();
  helloc();
}

So, the hello function in a.js calls hellob in b.js and helloc in c.js.

Here’s the JavaScript from b.js and c.js:

// /src/b.js
export function hellob() {
  console.log("hello b");
}
// /src/c.js
export function helloc() {
  console.log("hello c");
}

Notice that we need to provide the full relative path to the file we are importing from, and we also need to include the file extension. We are probably more used to a bare import specifier as follows:

import { hello } from "a";

We’ll come back to bare import specifiers later.

Notice also we didn’t have to declare all the modules in the HTML file. The browser resolved them at runtime.

It is important to note that you can not consume a JavaScript module from a normal script element. For example, if we try without the type attribute, the script element won’t be executed:

<script>
  // 💥 - Cannot use import statement outside a module
  import { hello } from "/src/a.js";
  hello();
</script>

Code written in JavaScript modules is executed in strict mode by default. So there is no need to have use strict at the top of the code:

<script type="module">
  let name = "Fred";
  let name = "Bob"; // 💥 - Identifier 'name' has already been declared
</script>

Nice!

JavaScript module errors

Let’s take a similar example from earlier where we have JavaScript modules a, b, c. Module a depends on b, and c. Modules b and c both have no dependencies.

Let’s say that c.js contains a runtime error:

export function helloc() {
  consol.log("hello c"); // 💥 - Uncaught ReferenceError: consol is not defined
}

Here’s how the code is invoked from the HTML file:

<script type="module">
  import { hello } from "/src/a.js";
  hello();
</script>

Here’s a.js:

import { hellob } from "/src/b.js";
import { helloc } from "/src/c.js";

export function hello() {
  hellob();
  helloc(); // 💥
  hellob(); // never executed
}

As we may expect, the second call to hellob is never reached.

What if the problem in c.js was a compile error:

export functio helloc() {
  console.log("hello c");
}

… no code in the module is executed:

<script type="module">
  // 💥 - Unexpected token 'export'
  // no code is executed
  import { hello } from "/src/a.js";
  hello();
</script>

Code in other modules is executed okay though.

Browser support

All the modern browsers support native modules, but unfortunately, IE doesn’t. However, there is a way we can use native modules with browsers that support them and provide a fallback for browsers that don’t. We achieve this using the nomodule attribute on the script element:

Rollup, can nicely output a module and nomodule bundle with a configuration like below:

export default [{
  ...
  output: {
    file: 'bundle-esm.js',
    format: 'es'
  }
},{
  ...
  output: {
    file: 'bundle.js',
    format: 'iife'
  }
}];

Neat!

Waterfall delivery

Let’s look at an example where we reference a module from a CDN:

<script type="module">
  import intersection from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.min.js";
  console.log(intersection([2, 1], [2, 3]));
</script>

Module intersection depends on other modules, and those modules depend on other modules. So all the dependencies are downloaded and parsed before the code is the script element is executed.

Module dependency downloads

Preloading modules

JavaScript modules can be preloaded using a modulepreload resource hint:

<link
  rel="modulepreload"
  href="https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.js"
/>

This means that this module is downloaded and parsed before other modules are downloaded:

Module dependency downloads with preload

Only Chrome and Edge support modulepreload at the moment. Firefox and Safari will fallback to the normal downloading of the module.

Dynamic import

Dynamic imports are where code can be imported at runtime, potentially based on a condition:

<script type="module">
  if (new Date().getSeconds() < 30) {
    import("/src/a.js").then(({ helloa }) =>
      helloa()
    );
  } else {
    import("/src/b.js").then(({ hellob }) =>
      hellob()
    );
  }
</script>

This is useful for large modules where some code has a low likelihood of being used. This can also reduce the apps memory footprint in the browser.

Using bare import specifiers with import maps

Circling back to how we reference modules in the import statement:

import { hello } from "/src/a.js";
import intersection from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.min.js

If we think about it, unless we specify the full path to the module, how would the browser know where to find it? So, the syntax makes sense, even if we are not used to it.

There is a way to use bare import specifiers with a proposed feature called import-maps. This is a map defined in a special importmap script element and needs to be defined before the script element that references the modules:

<script type="importmap">
  {
    "imports": {
      "b": "/src/b.js",
      "lowdash-intersection": "https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.min.js"
    }
  }
</script>

Each dependent module is given a bare import specifier name.

We can then use the bare import specifiers in the import statements:

<script type="module">
  import { hellob } from "b";
  hellob();
  import intersection from "lowdash-intersection";
  console.log(intersection([2, 1], [2, 3]));
</script>

Cool!

Import maps aren’t available in browsers at the moment. However, this feature is available in Chrome under an experimental flag: chrome://flags/#enable-experimental-web-platform-features.

Dependencies must publish ES Modules

An important point is that libraries must publish to the native module format for consumers to use the libraries as native modules. Unfortunately, this isn’t common at the moment. For example, React doesn’t publish to native modules yet.

The benefits of native modules over non-native

A benefit of native modules over something like an IIFE module is that there is less code that needs to be downloaded to the browser, parsed and then executed. Native modules can be downloaded and parsed in parallel, asynchronously as well. So, native modules may perform quicker for large dependency trees. Also, preloading modules may mean users can interact with pages quicker because this code is parsed off the main thread.

In addition to some performance gains, new browser features may be built on top of modules, so using native modules is future-proofing code.

Wrap up

Native modules are available for the most popular browsers in use today, and a fallback can be provided for IE. Rollup bundles can already publish to this format, and Webpack support appears to be on its way. Now, all we need is more libraries to start publishing to this format.

Did you find this post useful?

Let me know by sharing it on Twitter.
Click here to share this post on Twitter