Many modern web applications depend on JavaScript. When the complexity of your JavaScript increases, so does the maintenance cost. To improve the maintenance of complex JavaScript codebases, you most likely adhere to some clean code practices in your codebase.

One best practice in JavaScript is to modularise your codebase. Modularising a codebase leads to very direct benefits such as:

  • Ease of code sharing within a team: Many developers can work on disparate features without leading to code conflicts within a version control system
  • Ease of code sharing between the front-end and back-end components of your stack: An isolated module which does one thing only is primed to be used on more than one layers of your technology stack
  • Simplified code maintainability: Your understanding of where changes need to be made increases

For the first 20 years as a language, there was no standard for modules in JS. But even prior to a language standard approach for creating and consuming JavaScript modules, there was a community-led effort to try to standardize efforts:

One of the recent community standards is CommonJS where you can have code like this:

// main.js
const message = require('./module-1');
console.log(message); // 'hello'

// module-1.js
module.exports = 'Hello';

In the above simplified example, as module-1.js grows in complexity, the file main.js will not necessarily have to become complex too. CommonJS is currently the default module system for Node.js. JavaScript bundling tools like Browserify, Babel & Rollup supported CommonJS with minimal configuration code. CommonJS thrived as a module loading solution, and in many current JavaScript projects, still does. However AMD and CommonJS are de facto standards rather than built-in language standards, and are not natively supported in browsers. Some module bundling tools will increase the total size of the JavaScript payload by adding in CommonJS support.

ECMAScript modules (ESM) are an official standard and are already present in Chrome Canary (version 64) & Safari (version 11). They offer techniques for creating JavaScript modules and consuming JavaScript modules. Additionally, the standard also specifies a technique to dynamically load JavaScript modules through a feature named Dynamic Imports.

ESM

ESM includes three important features: imports, exports & dynamic imports. The ESM syntax is not significantly different to module solutions in other languages, so we will jump straight into some examples. Here is a basic code example which demonstrates imports, exports and dynamic imports.

Within the main HTML file:

<script type="module" src="main.js"></script>

The contents of ‘main.js’

import module1 from './module-1.js';

console.log(module1); // I am module 1

async function load() {
	const {default: module2} = await import('./module-2.js');
	console.log(module2); // I am module 2
}

load();

The contents of ‘module-1.js’

export default 'I am module 1';

The contents of ‘module-2.js’

export default 'I am module 2';

Side note: Notice how the HTML has a script tag with a type=”module” attribute. You can also use the nomodule attribute to conditionally load JavaScript with modern features in browsers which support them.

Module downloads though Chrome DevTools

In the above code example, the follow chain of events occur:

  1. The main HTML file loads the first JavaScript module, main.js. The main.js file is referenced via a script tag, this script tag has an attribute of ‘type’ and value of ‘module’
  2. The first JavaScript module (main.js) loads a JavaScript module named ‘module-1.js’. The load is done via an import statement
  3. The main.js file loads another JavaScript module named ‘module-2.js’. The load is done through a function call, through an API named Dynamic Imports

Note: All three JavaScript files mentioned above are downloaded asynchronously. The waterfall chart in the Network Panel screenshot above demonstrates the order in which files are downloaded.

JavaScript modules have the following characteristics:

  • A module can import other JavaScript modules
  • A module can export a subset of the full module
  • Top level variables defined in a module do not pollute the global scope and are local to that module
  • JavaScript modules are designed in such a way that the code structure of imports and exports can be statically analysed
  • JavaScript modules use defer by default. If you want immediate script execution, you can use the async attribute
  • JavaScript modules are expected to follow strict mode

Exports

In ESM, there are two types of exports: default exports and named exports. There can only be a single default export per module, however one module can have many named exports.

Here is a code example of both types of exports:

export const one = 1; // Named export
export const two = 2; // Named export
export default 3; // Default export

So what is the difference between the two exports? To extract a named export from a module, you must specify its name upfront. To extract a default export, you may select any name you wish. This is best demonstrated through code:

import {one, two} from './my-module.js';
import myModule from './my-module.js';

console.log(one); // 1
console.log(two); // 2
console.log(myModule); // 3

Notice how the variable ‘myModule’ automatically receives the default export. When creating a named export, you may wish to export an existing variable with a different name. The ESM standard enables this with the following syntax:

const hello = 'hello';
export { hello as greeting }

In the code example above, the named export is ‘greeting’.

In ESM, you do not export a value or a reference, but rather, you export a binding. Within CommonJS and AMD, the value you export from a module is a copy. When you import this same module, the imported value is also a copy and is disconnected from the original exported value. In ESM, you import a binding from a module, this binding includes a direct connection to the exported value.

Notice this module code:

// module-1.js
let number = 1;

function inc() {
	number++;
}

export {number, inc};

And the main.js entry point:

import {number, inc} from './module-1.js';

console.log(number); // 1
inc();
console.log(number); // 2

See the article What do ES6 modules export For more details on this topic.

Re-exports

There is the concept of ‘re-exporting’ where an ES module can export the exports of another module like this:

export { message as default } from './another-module.js';

The code above imports the named export ‘message’ from ‘another-module.js’ and binds it to the default export of itself.

By now, we should understand the concept of exporting a module. Let’s move onto importing.

Imports

Like exports, an import statement must be at the top-level of a module. For example, it cannot be enclosed within a function. As we’ve seen from the earlier example, you can import a default export like this:

import module1 from ‘./module-1.js’;

And you can import a named export like this:

import {name} from ‘./module-1.js’;

You can alias a named export when importing it, like this:

import { namedExport as renamedExport } from ‘./module-1.js’

You can also alias a default export during importing:

import {default as module1} from ‘./module-1.js’

You can import all named exports from a module like this:

import * as everything from ‘./module-1.js’

For the line of of code above, consider that the contents of module-1 looks like this:

export const one = 1;
export const two = 2;
export const three = 3;

After importing, the variable ‘everything’ looks like this:

Module exported preview

Import statements also support importing the default export, as well as named exports, all on one line:

import defaultExport, { namedExport1, namedExport2 } from './module-1.js'

The technique of importing a module resembles ES6 destructuring assignments, however it’s important to note they are different. For example, observe this destructuring operation:

const account = {
    firstName: 'umar',
    verified: true
};

const { firstName: username } = account;

console.log(username); // umar

Observe how this assignment technique extracts the firstName property and assigns it to a variable named username. This technique does not work with importing, instead, use the following:

import { namedExport as alias } from ‘./module-1.js’

To import only what you need provides greater flexibility over what could be done with CJS/AMD, and somewhat negates a trend to create a separate module for every function call.

Modules which invoke/initialise themselves straight after being loaded are an anti-pattern since you lose control of the point at which they execute, however such modules do exist. When you import a module, you can omit the named exports and the word ‘from’, like this:

import './module-1.js'

The ‘module-1.js’ module will execute, and will not be assigned to a variable.

Another note on the import syntax. You can import a remote URL directly, for example:

import last from 'https://raw.githubusercontent.com/lodash/lodash/00705afc19c09227561daddd6905c855649f9e6d/last.js';

console.log(last([1, 2, 3])); // 3

The code example above imports a remote module from GitHub (served from GitHub User Content). The module on GitHub is authored as an ES module and is compatible with the import syntax.

Notice that the import statement is quite long, to address this, you may try to put the URL into its own variable:

const url = 'https://raw.githubusercontent.com/lodash/lodash/00705afc19c09227561daddd6905c855649f9e6d/last.js'
import last from url; // This is invalid

This is invalid JavaScript. The module specifier (the part in strings) part of an import statement must be statically analysable. Its value cannot be computed through the result of a runtime evaluation. There is an alternative API however.

Dynamic imports

Dynamic Imports offers a function-like import call for module loading. It is a feature added to meet the needs of asynchronous module loading in browsers, a feature popularized by AMD.

Dynamic Imports are part of the ES2018 specification and have shipped in Chrome Canary (version 64) & Safari (version 11). Dynamic Imports are also supported in Babel, TypeScript and can be used in webpack to achieve code splitting.

As an example, you may wish to combine dynamic imports with navigator.deviceMemory so that a computationally costly module is dynamically loaded only if the device has enough RAM.

if (navigator.deviceMemory > 1) {
	const module = await import('./constly-module.js');
}

Dynamic imports are promise based. You can combine multiple dynamic imports and await their result in fairly intuitive code:

const [module1, module2, module3] = await Promise.all([
	import('./module-1.js'),
	import('./module-2.js'),
	import('./module-3.js')
]);

In the code example above, three modules are dynamically downloaded without having to resort to callback based patterns.

Debugging with Chrome DevTools

When you use one module to import another module, the dependency chain may be unclear. The Initiator column in the Network Panel displays exactly which file and line of code triggered the download of the relevant JavaScript module.

Chrome DevTools Initiator column

Note: This capability is available in Chrome DevTools. The usual debugging abilities you’re familiar with, such as setting a breakpoint, are also compatible with ES module files.

Node.js

Node.js now has experimental support for ESM as of version 8.5.0. For a file to be treated as ESM, it must have an ‘.mjs’ file extension.

Consider this code example:

import fs from 'fs';
fs.writeFile('message.txt', 'Hello!');

To run this Node.js script from the terminal:

node --experimental-modules index.mjs

As specified in the documentation, existing CommonJS modules can be consumed with ESM inside of Node.js.

For Node.js, there is an @std/esm package which enables ESM in Node.js 4+ in an unobtrusive manner.

Bundling

When using ES modules in a browser environment, you may wish to bundle all of your JavaScript modules. There are a number of tools to help accomplish this.

In this example, we use Rollup. Here’s the entry file for the project:

// main.js
import module1 from './module-1.js';
import module2 from './module-2.js';
import {add} from './module-3.js';

console.log({module1, module2});
console.log(add(1, 1));

Module one and two export simple pieces of text. Module three looks like this:

function add(a, b) {
	return a + b;
}

function subtract(a, b) {
	return a - b;
}

export {add, subtract};

Notice, in the code above, that only the ‘add’ function is imported from the main entry point. The subtract function is unused. Using rollup, we can combine all modules into a single bundle.

The example above uses a tool called rollup. Rollup works from the command line, but also through other build tooling like Gulp.

Module bundling is also possible with webpack. See this webpack.config.js file:

const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
	entry: './main.js',
	output: {
		filename: './bundle.js'
	},
	plugins: [
		new UglifyJSPlugin()
	]
};

Note: The UglifyJSPlugin is required for tree shaking.

Running the command:

./node_modules/.bin/webpack –config webpack.config.js

From the terminal, on the same modules and entry point used in the earlier example, creates a single minified bundle which does not contain the subtract function.

Tree shaking (a feature also known as dead-code elimination) occurs to ensure unused exports are not part of the final bundle. This is a powerful feature since the total file size of the bundle will be smaller, which means a lighter payload for the user. There is also less JavaScript to parse + evaluate for the JavaScript engine, leading to potentially large performance wins on the device.

Here is the rollup command to create a bundle.

$ rollup main.js –output.format iife –output.file bundle.js</p>

The created bundle looks like this:

(function () {
'use strict';

var module1 = 'I am module 1';

var module2 = 'I am module 2';

function add(a, b) {
	return a + b;
}

console.log({module1, module2});
console.log(add(1, 1));

}());

Notice the subtract function is not present in this bundle thanks to tree-shaking.

Conclusion

ESM enables a standard mechanism of modularising your JavaScript codebase. Having a unified way of doing this should mean an end to extra JavaScript payloads being sent over the network just to mimic such functionality which was once missing from browsers. While full cross-browser support is not yet present, it is an official web standard which has been agreed on by all modern browsers.

Until HTTP/2 proves loading many non-bundled resources does not negatively impact the user, we will most likely continue to bundle all of our JavaScript resources into one or two bundles. Some JavaScript bundlers support ESM such as rollup and webpack. Tools like Babel include transforms for ESM so your ES modules can transpile into other popular module formats like CommonJS, which can then be bundled by an even larger array of bundlers.

Further Reading