Link Search Menu Expand Document

Module 5: ECMAScript Modules

When JavaScript was first added to web browsers back in the days of Netscape Navigator, it was never intended to do much more than mouseover animations and basic form validation.1 But it turns out that being built into every web browser on the planet meant JavaScript became incredibly popular and successful, and today we’re using it to build applications that are vastly bigger and more complex than anybody ever imagined back when HTML 3.2 was still new and shiny.

As applications get bigger, we need more powerful and more flexible mechanisms to manage our code – but for a long time, JavaScript didn’t give us anything beyond the basic <script> tag. In the absence of anything like namespaces or header files, developers figured out all sorts of clever ways to manage the visibility of their JavaScript libraries and avoid naming conflicts and clashes with other libraries, but it was all a bit of a hack.

The Problem with <script>

Let’s say we have a simple graphics library written in JavaScript. We’ve organised our code into files, so we have ellipse.js, which contains code for drawing ellipses, and rectangle.js , which contains code for rectangles. We have code for drawing circles - but a circle is just a special ellipse, so our circle.js uses the Ellipse function. And for drawing squares, we use the Rectangle function.

Finally, we have a library of complex shapes, like smiley faces and flags, that uses these primitive shape functions.

If we’re just using ordinary <script> tags, we have no way to tell the browser that our Circle function depends on our Ellipse function – the only thing we can do is to make sure we import our scripts in exactly the right order:

image-20210415122708735

Any functions or variables that are declared in any of our scripts will become part of the browser’s global namespace2 – so if two files declare a function with the same name, the first function will get overwritten by the second. To see an example of this problem in action, take a look at the-problem-with-scripts.html:

<!DOCTYPE html>
<html>

<head>
    <title>The Problem with Scripts</title>
</head>

<body>
    <p>This page includes several script files.</p>
    <p>Both script files declare a function called <code>init()</code>, but the <code>init()</code> function from <a
            href="problem-script-2.js">problem-script-2</a> will overwrite the function from <a href="problem-script-1.js">problem-script-1</a>
    </p>    
    <script src="problem-script-1.js"></script>
    <script src="problem-script-2.js"></script>
    <script>
        init();
    </script>
</body>

</html> 

The page includes two different scripts, both of which declare a function called init(), but the function from the second script overwrites the first:

// problem-script-1.js
function init() {
    document.write('<p>Script #1 has initialised!</p>');
}
// problem-script-2.js
function init() {
    document.write('<p>Script #2 has initialised!</p>');
}

This is just one of the problems with loading JS via <script> tags. Scripts are parsed in the order they’re found in the page, so if you have components which depend on other components, you need to take care to write your script tags in the right order - if you’ve ever had a script or widget fail because it relied on the jQuery object and jQuery wasn’t loaded until further down the page, this is why.

What happened to HTML Imports?

For a while, there was a proposal to add an import mechanism to HTML directly; browsers would be able to include fragments of markup and script from other files using syntax like <link rel="import" href="my-component.html" />, similar to the mechanism used to import stylesheets. HTML Imports were originally part of the Web Components spec, but they were quickly replaced by JavaScript modules and are no longer supported in most web browsers. The HTML Imports page on MDN has more details if you’re interested.

ES Modules to the Rescue

The code for this section is available to download:

Download the code: paintbox.zip

It took a long while, but finally, in around 2016, ECMA – the standards body who control ECMAScript, the specification on which JavaScript is based – published a standard for working with modules in JavaScript. This was published as part of the ES6 language standard, and is commonly known as the ES Module system. It was rapidly adopted by server-side technologies like nodeJS, but took a few years before it was widely supported by browsers. As of early 2021, the core parts of ES modules – the JavaScript import and export statements, and the <script type="module" /> feature – are supported in Edge, Firefox, Chrome, Safari and Opera on desktop, and in most mobile browsers.3

A module is a JavaScript file that exports one or more objects, functions, or variables, via the export keyword:

// ellipse.js
function Ellipse(canvasId, x, y, width, height, color) {
    const canvas = document.getElementById(canvasId);
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.ellipse(x,y,width,height, 0, 0, Math.PI * 2);
    ctx.fill();
}

export default Ellipse;
// circle.js
import Ellipse from './ellipse.js';

function Circle(canvasId, x,y,size,color) {
    // A circle is just a special ellipse where width = height
    Ellipse(canvasId,x,y,size,size,color);
}

export { Ellipse, Circle };
// shapes.js
import { Ellipse, Circle } from './circle.js';
import { Square, Rectangle } from './square.js';

function HappyFace(canvasId, x,y) {
    Circle(canvasId, x, y, 50, 'orange');
    Circle(canvasId, x, y + 5, 30, 'black');
    Ellipse(canvasId, x, y - 5, 35, 30, 'orange');
    Circle(canvasId, x - 15, y - 10, 8, 'black');
    Circle(canvasId, x + 15, y - 10, 8, 'black');
}

function Flag(canvasId, x, y) {
    Rectangle(canvasId, x, y, 60, 15, 'black');
    Rectangle(canvasId, x, y+15, 60, 15, 'red');
    Rectangle(canvasId, x, y+30, 60, 15, 'gold');
}

function PinkFlower(canvasId, x, y) {
    Ellipse(canvasId, x-15, y, 20, 10, 'pink');
    Ellipse(canvasId, x+15, y, 20, 10, 'pink');
    Ellipse(canvasId, x, y-15, 10, 20, 'pink');
    Ellipse(canvasId, x, y+15, 10, 20, 'pink');
    Circle(canvasId, x, y, 15, 'orange');
}

export { HappyFace, Flag, PinkFlower };

By importing our JavaScript into the browser as a module, we enable the ES6 module feature, which means our JavaScript modules can import other modules; instead of everything ending up in the global namespace and having to manage dependencies manually, we get something closer to this:

Diagram showing how module imports are resolved using ES6 modules

Try it live: paintbox.html

Working with imports and exports

A JS module has to export something, and any other code that imports that module will only see the methods, variables or functions that have been explicitly exported from the module.

A module that only exports a single feature can use something called the default export:

// say-hello.js
export default function SayHello(name) {
  console.log(`Hello, ${name!}`);
}
import Hello from './hello-en.js';
import Bonjour from './hello-fr.js';
SayHello('Alice');
Bonjour('Bob');

Live demo: 01-default-import.html

Modules can also provide multiple named exports, either by exporting individual functions, variables and classes, as in this example:

// greetings-en.js
export function SayHello(name) {
	console.log(`Hello, ${name}`);
}
export function SayGoodbye(name) {
    console.log(`Goodbye, ${name}`);
}

Alternatively, you can provide a list of arguments to the export keyword in your module definition:

// greetings-fr.js
function SayHello(name) {
	console.log(`Bonjour, ${name}`);
}
function SayGoodbye(name) {
    console.log(`Au revoir, ${name}`);
}

export { SayHello, SayGoodbye };

When importing a module that uses named exports, you will usually specify a namespace for the import:

<script type="module">
import * as Greetings from './greetings-en.js';
Greetings.SayHello('alice');
Greetings.SayGoodbye('bob');
</script>

Namespaces are particularly useful if you’re importing two modules which expose members with the same name:

import * as EnglishGreetings from './greetings-en.js';
import * as FrenchGreetings from './greetings-fr.js';
EnglishGreetings.SayHello('Alice');
EnglishGreetings.SayGoodbye('Bob');
FrenchGreetings.SayHello('Charlotte');
FrenchGreetings.SayGoodbye('Davide');

Live demo: 02-namespaced-imports.html

You can also specify exactly which methods and members should be imported from a module:

<script type="module">
import { SayHello, SayGoodbye } from './phrases.js';
SayHello('alice');
SayGoodbye('bob');
</script>

Live demo: 03-named-imports.html

You can even provide member-level aliases when importing named exports:

import { SayHello as Hello, SayGoodbye as Goodbye } from './greetings-en.js';
import { SayHello as Bonjour, SayGoodbye as AuRevoir } from './greetings-fr.js';
Hello('Alice');
Goodbye('Bob');
Bonjour('Charlotte');
AuRevoir('Davide');

Live demo: 04-import-aliases.html

You can combine these import mechanisms in various ways, as shown in this list of valid import syntax forms from MDN:

import defaultExport from "module-name";
import * as name from "module-name";
import { export1 } from "module-name";
import { export1 as alias1 } from "module-name";
import { export1 , export2 } from "module-name";
import { foo , bar } from "module-name/path/to/specific/un-exported/file";
import { export1 , export2 as alias2 , [...] } from "module-name";
import defaultExport, { export1 [ , [...] ] } from "module-name";
import defaultExport, * as name from "module-name";
import "module-name";
var promise = import("module-name");

Source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#syntax

Things to watch out for

ES modules are a great solution to an important problem, but there’s a couple of things to watch out for:

Modules must be loaded URLS - not bare filenames

If you’ve used ES modules with a platform such as NodeJS, you might have seen import statements like:

import Module from "module";
import { Module } from "module.js";

These won’t work in browsers - because a browser doesn’t have access to the filesystem where the module files are stored, it can’t handle things like omitting the file extension. We need to load our modules by providing a full relative or absolute URL, as in the following examples:

import { Module } from './module.js';
import Module from '/js/modules/module.js';
import * as Module from 'https://modules.example.com/js/modules/module-4.2.3.min.js';

Strict mode

JS modules will always be loaded in strict mode, a restricted variant of JavaScript introduced in ES5 that fixes some problems and issues that were widespread in earlier versions of JavaScript. You can read more about strict mode at MDN; for now, just remember that if you have some JS code that worked fine until you turned it into a module, you may be relying on some behaviour that doesn’t work in strict mode.

You can’t use file:/// urls

You can’t host ES modules via a filesystem URL. If you’re developing ES modules locally, you’ll need to run a local web server. If you’re already using something like Webpack or Jekyll, that’ll work just fine. If you need a web server for hosting your development projects, check out spark, the “emergency web server” – it’s a standalone executable that will turn any folder into a local HTTP server.

ℹ Even if your project ends up importing the same script multiple times, it will only be loaded and parsed once.

Modules are always deferred

Many developers use the defer attribute when loading scripts – this tells the browser to defer script execution until the page has been loaded and parsed. Scripts loaded with type="module" will always be deferred, so you don’t need to use the defer attribute.

MIME types, file extensions and CORS headers

JavaScript modules must be served by your web server with a JavaScript MIME type – normally text/javascript or application/javascript. If your server is returning .js resources with the wrong MIME type, your browser will complain that “The server responded with a non-JavaScript MIME type” and your modules won’t work.

The Google V8 documentation recommends that JavaScript modules should use the file extension .mjs; the Mozilla Developer Network uses .js in all their examples and documentation (here’s why). Whether you use .js or .mjs is up to you, but if you’re using .mjs, remember to configure your web server to host .mjs files with the correct MIME type.

Finally, if you’re loading JS modules from a content delivery network (CDN) or from another site, the server that’s hosting the modules will need to include a valid CORS4 header, such as Access-Control-Allow-Origin: *

Exercise: working with ES Modules

Download the code: paintbox.zip

Extend the paintbox example by adding three new flags.

  • Create a new JS module called flags-asia.js, which exports functions to draw:
    • The flag of Japan
    • The flag of Indonesia
    • The flag of the United Arab Emirates
  • Create a new JS module which exports a single function to draw the flag of Nigeria
  • Add each new flag to the Paintbox application as a new option in the radio list underneath the canvas

BONUS POINTS

  1. Draw the flag of Vietnam
  2. Draw the flag of Sudan
  3. Draw the flag of South Africa

ES Modules: Review

  • ES Modules provides a way to organise JavaScript code and manage dependencies between code libraries
  • It replaces the short-lived HTML Imports proposal, which was introduced at the same time as other web component technologies but has now been deprecated
  • ES modules is activated by specifying type="module" when adding a script element to your HTML page.
  • Modules can expose access to variables, functions and classes using the export keyword
  • Modules and script blocks can use import to import elements from other modules.
  • Modules must be hosted on a web server, and returned with an application/javascript MIME type and valid CORS headers.
  • Web browsers will always defer the loading of module scripts until the page has been loaded and parsed.
  • Module scripts will always be interpreted in strict mode.

References and Further Reading


  1. OK, and wiring together Java applets. No, really… that’s what JavaScript was supposed to be used for; we’d use Java to build all the components, and JavaScript to wire them together. Hey, it seemed like a good idea at the time. 

  2. Top-level functions are actually added to the window object, which is effectively the global scope for client-side JavaScript. 

  3. caniuse.com support for import, export and modules via script tag 

  4. Cross-Origin Resource Sharing; a mechanism that a web server can use to control whether content can be shared with pages hosted on other domains, websites or ports.