- 24. Modules
- 24.1. Before modules: scripts
- 24.2. Module systems created prior to ES6
- 24.3. ECMAScript modules
- 24.4. Named exports
- 24.5. Default exports
- 24.6. Naming modules
- 24.7. Imports are read-only views on exports
- 24.8. Module specifiers
- 24.9. Syntactic pitfall: importing is not destructuring
- 24.10. Preview: loading modules dynamically
- 24.11. Further reading
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:
In the script file, we simulate a module:
var myModule = function () { // Open IIFE
// Imports (via global variables)
var importedFunc1 = otherLibrary1.importedFunc1;
var importedFunc2 = otherLibrary2.importedFunc2;
// Body
function internalFunc() {
// ···
}
function exportedFunc() {
importedFunc1();
importedFunc2();
internalFunc();
}
// Exports (assigned to global variable `myModule`)
return {
exportedFunc: exportedFunc,
};
}(); // 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.
// Imports
var importedFunc1 = require('other-module1').importedFunc1;
var importedFunc2 = require('other-module2').importedFunc2;
// Body
function internalFunc() {
// ···
}
function exportedFunc() {
importedFunc1();
importedFunc2();
internalFunc();
}
// Exports
module.exports = {
exportedFunc: exportedFunc,
};
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.
define(['other-module1', 'other-module2'],
function (otherModule1, otherModule2) {
var importedFunc1 = otherModule1.importedFunc1;
var importedFunc2 = otherModule2.importedFunc2;
function internalFunc() {
// ···
}
function exportedFunc() {
importedFunc1();
importedFunc2();
internalFunc();
}
return {
exportedFunc: exportedFunc,
};
});
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:
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:
lib/my-math.js
main1.js
main2.js
Module my-math.js
has two named exports: square
and MY_CONSTANT
.
Module main1.js
has a single named import, square
:
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
:
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:
my-func.js
main.js
Module my-func.js
has a default export:
Module main.js
default-imports the exported function:
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
:
Second, you can directly default-export values. In that style, export default
is itself much like a declaration.
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.
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:
./my-module.js
./some-func.js
- The names of namespace imports are lowercased and camel-cased:
- The names of default imports are lowercased and camel-cased:
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):
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:
counter.js
main.js
counter.js
exports a (mutable!) variable and a function:
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:
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:
'./some/other/module.js'
'../../lib/counter.js'
- Absolute paths: start with slashes. Example:
'/home/jane/file-tools.js'
- Full URLs: include protocols (technically, paths are URLs, too). Example:
'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.
'lodash'
'mylib/string-tools'
'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'
- Built-in modules (
- 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:
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:
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: Theimport()
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:
lib/my-math.js
main1.js
main2.js
We have already seen module my-math.js
:
This is what using import()
looks like in main1.js
:
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 inmain2.js
, but via a so-called async function, which provides nicer syntax for Promises.
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
- More on
import()
: “ES proposal:import()
– dynamically importing ES modules” on 2ality. - For an in-depth look at ECMAScript modules, consult “Exploring ES6”.