Please support this book: buy it or donate

24. Modules



The current landscape of JavaScript modules is quite diverse: ES6 brought built-in modules, but the module systems that came before them, are still around, too. Understanding the latter helps understand the former, so let’s investigate.

24.1. Before modules: scripts

Initially, browsers only had scripts – pieces of code that were executed in global scope. As an example, consider an HTML file that loads a script file via the following HTML element:

  1. <script src="my-library.js"></script>

In the script file, we simulate a module:

  1. var myModule = function () { // Open IIFE
  2. // Imports (via global variables)
  3. var importedFunc1 = otherLibrary1.importedFunc1;
  4. var importedFunc2 = otherLibrary2.importedFunc2;
  5. // Body
  6. function internalFunc() {
  7. // ···
  8. }
  9. function exportedFunc() {
  10. importedFunc1();
  11. importedFunc2();
  12. internalFunc();
  13. }
  14. // Exports (assigned to global variable `myModule`)
  15. return {
  16. exportedFunc: exportedFunc,
  17. };
  18. }(); // Close IIFE

Before we get to real modules (which were introduced with ES6), all code is written in ES5 (which didn’t have const and let, only var).

myModule is a global variable. The code that defines the module is wrapped in an immediately invoked function expression (IIFE). Creating a function and calling it right away, only has one benefit compared to executing the code directly (without wrapping it): All variables defined inside the IIFE, remain local to its scope and don’t become global. At the end, we pick what we want to export and return it via an object literal. This pattern is called the revealing module pattern (coined by Christian Heilmann).

This way of simulating modules has several problems:

  • Libraries in script files export and import functionality via global variables, which risks name clashes.
  • Dependencies are not stated explicitly and there is no built-in way for a script to load the scripts it depends on. Therefore, the web page has to load not just the scripts that are needed by the page, but also the dependencies of those scripts, the dependencies’ dependencies, etc. And it has to do so in the right order!

24.2. Module systems created prior to ES6

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 (targeted at the server side) and AMD (Asynchronous Module Definition, targeted at the client side).

24.2.1. Server side: CommonJS modules

The original CommonJS standard for modules was mainly created for server and desktop platforms. It was the foundation of the module system of Node.js where it achieved incredible popularity. Contributing to that popularity were Node’s package manager, npm, and tools that enabled using Node modules on the client side (browserify and webpack).

From now on, I use the terms CommonJS module and Node.js module interchangeably, even though Node.js has a few additional features. The following is an example of a Node.js module.

  1. // Imports
  2. var importedFunc1 = require('other-module1').importedFunc1;
  3. var importedFunc2 = require('other-module2').importedFunc2;
  4. // Body
  5. function internalFunc() {
  6. // ···
  7. }
  8. function exportedFunc() {
  9. importedFunc1();
  10. importedFunc2();
  11. internalFunc();
  12. }
  13. // Exports
  14. module.exports = {
  15. exportedFunc: exportedFunc,
  16. };

CommonJS can be characterized as follows:

  • Designed for servers.
  • Modules are meant to be loaded synchronously.
  • Compact syntax.

24.2.2. Client side: AMD (Asynchronous Module Definition) modules

The AMD module format was created to be easier to use in browsers than the CommonJS format. Its most popular implementation is RequireJS. The following is an example of a RequireJS module.

  1. define(['other-module1', 'other-module2'],
  2. function (otherModule1, otherModule2) {
  3. var importedFunc1 = otherModule1.importedFunc1;
  4. var importedFunc2 = otherModule2.importedFunc2;
  5. function internalFunc() {
  6. // ···
  7. }
  8. function exportedFunc() {
  9. importedFunc1();
  10. importedFunc2();
  11. internalFunc();
  12. }
  13. return {
  14. exportedFunc: exportedFunc,
  15. };
  16. });

AMD can be characterized as follows:

  • Designed for browsers.
  • Modules are meant to be loaded asynchronously. That’s a crucial requirement for browsers, where code can’t wait until a module has finished downloading. It has to be notified once the module is available.
  • The syntax is slightly more complicated. On the plus side, AMD modules can be executed directly, without customized creation and execution of source code (think eval()). That is not always permitted on the web.

24.2.3. Characteristics of JavaScript modules

Looking at CommonJS and AMD, similarities between JavaScript module systems emerge:

  • There is one module per file (AMD also supports more than one module per file).
  • Such a file is basically a piece of code that is executed:
    • Exports: That code contains declarations (variables, functions, etc.). By default, those declarations remain local to the module, but you can mark some of them as exports.
    • Imports: The module can import entities from other modules. Those other modules are identified via module specifiers (usually paths, occasionally URLs).
  • Modules are singletons: Even if a module is imported multiple times, only a single instance of it exists.
  • No global variables are used. Instead, module specifiers serve as global IDs.

24.3. ECMAScript modules

ECMAScript modules were introduced with ES6: They stand firmly in the tradition of JavaScript modules and share many of the characteristics of existing module systems:

  • With CommonJS, ES modules share the compact syntax, better syntax for single exports than for named exports (so far, we have only seen named exports) and support for cyclic dependencies.

  • With AMD, ES modules share a design for asynchronous loading and configurable module loading (e.g. how specifiers are resolved).

ES modules also have new benefits:

  • Their syntax is even more compact than CommonJS’s.
  • Their modules have a static structure (that can’t be changed at runtime). That enables static checking, optimized access of imports, better bundling (delivery of less code) and more.
  • Their support for cyclic imports is completely transparent.
    This is an example of ES module syntax:
  1. import {importedFunc1} from 'other-module1';
  2. import {importedFunc2} from 'other-module2';
  3. function internalFunc() {
  4. ···
  5. }
  6. export function exportedFunc() {
  7. importedFunc1();
  8. importedFunc2();
  9. internalFunc();
  10. }

From now on, “module” means “ECMAScript module”.

24.3.1. ECMAScript modules: three parts

ECMAScript modules comprise three parts:

  • Declarative module syntax: What is a module? How are imports and exports declared?
  • The semantics of the syntax: How are the variable bindings handled that are created by imports? How are exported variable bindings handled?
  • A programmatic loader API for configuring module loading.
    Parts 1 and 2 were introduced with ES6. Work on Part 3 is ongoing.

24.4. Named exports

Each module can have zero or more named exports.

As an example, consider the following three files:

  1. lib/my-math.js
  2. main1.js
  3. main2.js

Module my-math.js has two named exports: square and MY_CONSTANT.

  1. let notExported = 'abc';
  2. export function square(x) {
  3. return x * x;
  4. }
  5. export const MY_CONSTANT = 123;

Module main1.js has a single named import, square:

  1. import {square} from './lib/my-math.js';
  2. assert.equal(square(3), 9);

Module main2.js has a so-called namespace import – all named exports of my-math.js can be accessed as properties of the object myMath:

  1. import * as myMath from './lib/my-math.js';
  2. assert.equal(myMath.square(3), 9);

24.5. Default exports

Each module can have at most one default export. The idea is that the module is the default-exported value. A module can have both named exports and a default export, but it’s usually better to stick to one export style per module.

As an example for default exports, consider the following two files:

  1. my-func.js
  2. main.js

Module my-func.js has a default export:

  1. export default function () {
  2. return 'Hello!';
  3. }

Module main.js default-imports the exported function:

  1. import myFunc from './my-func.js';
  2. assert.equal(myFunc(), 'Hello!');

Note the syntactic difference: The curly braces around named imports indicate that we are reaching into the module, while a default import is the module.

The most common use case for a default export is a module that contains a single function or a single class.

24.5.1. The two styles of default-exporting

There are two styles of doing default exports.

First, you can label existing declarations with export default:

  1. export default function foo() {} // no semicolon!
  2. export default class Bar {} // no semicolon!

Second, you can directly default-export values. In that style, export default is itself much like a declaration.

  1. export default 'abc';
  2. export default foo();
  3. export default /^xyz$/;
  4. export default 5 * 7;
  5. export default { no: false, yes: true };

Why are there two default export styles? The reason is that export default can’t be used to label const: const may define multiple values, but export default needs exactly one value.

  1. // Not legal JavaScript!
  2. export default const foo = 1, bar = 2, baz = 3;

With this hypothetical code, you don’t know which one of the three values is the default export.

24.6. Naming modules

There are no established best practices for naming module files and the variables they are imported into.

In this chapter, I’ve used the following naming style:

  • The names of module files are dash-cased and start with lowercase letters:
  1. ./my-module.js
  2. ./some-func.js
  • The names of namespace imports are lowercased and camel-cased:
  1. import * as myModule from './my-module.js';
  • The names of default imports are lowercased and camel-cased:
  1. import someFunc from './some-func.js';

What are the rationales behind this style?

  • npm doesn’t allow uppercase letters in package names (source). Thus, we avoid camel case, so that “local” files have names that are consistent with those of npm packages.

  • There are clear rules for translating dash-cased file names to camel-cased JavaScript variable names. Due to how we name namespace imports, these rules work for both namespace imports and default imports.

I also like underscore-cased module file names, because you can directly use these names for namespace imports (without any translation):

  1. import * as my_module from './my_module.js';

But that style does not work for default imports: I like underscore-casing for namespace objects, but it is not a good choice for functions etc.

24.7. Imports are read-only views on exports

So far, we have used imports and exports intuitively and everything seems to have worked as expected. But now it is time to take a closer look at how imports and exports are really related.

Consider the following two modules:

  1. counter.js
  2. main.js

counter.js exports a (mutable!) variable and a function:

  1. export let counter = 3;
  2. export function incCounter() {
  3. counter++;
  4. }

main.js name-imports both exports. When we use incCounter(), we discover that the connection to counter is live – we can always access the live state of that variable:

  1. import { counter, incCounter } from './counter.js';
  2. // The imported value `counter` is live
  3. assert.equal(counter, 3);
  4. incCounter();
  5. assert.equal(counter, 4);

Note that, while the connection is live and we can read counter, we cannot change this variable (e.g. via counter++).

Why do ES modules behave this way?

First, it is easier to split modules, because previously shared variables can become exports.

Second, this behavior is crucial for cyclic imports. The exports of a module are known before executing it. Therefore, if a module L and a module M import each other, cyclically, the following steps happen:

  • The execution of L starts.
    • L imports M. L’s imports point to uninitialized slots inside M.
    • L’s body is not executed, yet.
  • The execution of M starts (triggered by the import).
    • M imports L.
    • The body of M is executed. Now L’s imports have values (due to the live connection).
  • The body of L is executed. Now M’s imports have values.
    Cyclic imports are something that you should avoid as much as possible, but they can arise in complex systems or when refactoring systems. It is important that things don’t break when that happens.

24.8. Module specifiers

One key rule is:

All ES module specifiers must be valid URLs and point to real files.

Beyond that, everything is still somewhat in flux.

24.8.1. Categories of module specifiers

Before we get into further details, we need to establish the following categories of module specifiers (which originated with CommonJS):

  • Relative paths: start with a dot. Examples:
  1. './some/other/module.js'
  2. '../../lib/counter.js'
  • Absolute paths: start with slashes. Example:
  1. '/home/jane/file-tools.js'
  • Full URLs: include protocols (technically, paths are URLs, too). Example:
  1. 'https://example.com/some-module.js'
  • Bare paths: do not start with dots, slashes or protocols. In CommonJS modules, bare paths rarely have file name extensions.
  1. 'lodash'
  2. 'mylib/string-tools'
  3. 'foo/dist/bar.js'

24.8.2. ES module specifiers in Node.js

Support for ES modules in Node.js is work in progress. The current plan (as of 2018-12-20) is to handle module specifiers as follows:

  • Relative paths, absolute paths and full URLs work as expected. They all must point to real files.
  • Bare paths:
    • Built-in modules (path, fs, etc.) can be imported via bare paths.
    • All other bare paths must point to files: 'foo/dist/bar.js'
  • The default file name extension for ES modules is .mjs (there will probably be a way to switch to a different extension, per package).

24.8.3. ES module specifiers in browsers

Browsers handle module specifiers as follows:

  • Relative paths, absolute paths and full URLs work as expected. They all must point to real files.
  • How bare paths will end up being handled is not yet clear. You may eventually be able to map them to other specifiers via lookup tables.
  • The file name extensions of modules don’t matter, as long as they are served with the content type text/javascript.
    Note that bundling tools such as browserify and webpack that compile multiple modules into single files are less restrictive with module specifiers than browsers, because they operate at compile time, not at runtime.

24.9. Syntactic pitfall: importing is not destructuring

Both importing and destructuring look similar:

  1. import {foo} from './bar.js'; // import
  2. const {foo} = require('./bar.js'); // destructuring

But they are quite different:

  • Imports remain connected with their exports.

  • You can destructure again inside a destructuring pattern, but the {} in an import statement can’t be nested.

  • The syntax for renaming is different:

  1. import {foo as f} from './bar.js'; // importing
  2. const {foo: f} = require('./bar.js'); // destructuring

Rationale: Destructuring is reminiscent of an object literal (incl. nesting), while importing evokes the idea of renaming.

24.10. Preview: loading modules dynamically

So far, the only way to import a module has been via an import statement. Limitations of those statements:

  • You must use them at the top level of a module. That is, you can’t, e.g., import something when you are inside a block.
  • The module specifier is always fixed. That is, you can’t change what you import depending on a condition, you can’t retrieve or assemble a specifier dynamically.
    An upcoming JavaScript feature changes that: The import() operator, which is used as if it were an asynchronous function (it is only an operator, because it needs implicit access to the URL of the current module).

Consider the following files:

  1. lib/my-math.js
  2. main1.js
  3. main2.js

We have already seen module my-math.js:

  1. let notExported = 'abc';
  2. export function square(x) {
  3. return x * x;
  4. }
  5. export const MY_CONSTANT = 123;

This is what using import() looks like in main1.js:

  1. const dir = './lib/';
  2. const moduleSpecifier = dir + 'my-math.js';
  3. function loadConstant() {
  4. return import(moduleSpecifier)
  5. .then(myMath => {
  6. const result = myMath.MY_CONSTANT;
  7. assert.equal(result, 123);
  8. return result;
  9. });
  10. }

Method .then() is part of Promises, a mechanism for handling asynchronous results, which is covered later in this book.

Two things in this code weren’t possible before:

  • We are importing inside a function (not at the top level).
  • The module specifier comes from a variable.
    Next, we’ll implement the exact same functionality in main2.js, but via a so-called async function, which provides nicer syntax for Promises.
  1. const dir = './lib/';
  2. const moduleSpecifier = dir + 'my-math.js';
  3. async function loadConstant() {
  4. const myMath = await import(moduleSpecifier);
  5. const result = myMath.MY_CONSTANT;
  6. assert.equal(result, 123);
  7. return result;
  8. }

Alas, import() isn’t a standard part of JavaScript yet, but probably will be, relatively soon. That means that support is mixed and may be inconsistent.

24.11. Further reading