Topics in This Chapter
When providing code to be reused by many programmers, it is important to separate the public interface from the private implementation. In an object-oriented programming language, this separation is achieved with classes. A class can evolve by changing the private implementation without affecting its users. (As you saw in Chapter 4, hiding private features is not yet fully supported in JavaScript, but this will surely come.)
A module system provides the same benefits for programming at larger scales. A module can make certain classes and functions available, while hiding others, so that the module’s evolution can be controlled.
Several ad-hoc module systems were developed for the JavaScript. In 2015, ECMAScript 6 codified a simple module system that is the topic of this short chapter.
A module provides features (classes, functions, or other values) for programmers, called the exported features. Any features that are not exported are private to the module.
A module also specifies on which other modules it depends. When a module is needed, the JavaScript runtime loads it together with its dependent modules.
Modules manage name conflicts. Since the private features of a module are hidden from the public, it does not matter what they are called. They will never clash with any names outside the module. When you use a public feature, you can rename it so that it has a unique name.
In this regard, JavaScript modules differ from Java packages or modules which rely on globally unique names.
It is important to understand that a module is different from a class. A class can have many instances, but a module doesn’t have instances. It is just a container for classes, functions, or values.
Consider a JavaScript developer who wants to make features available to other programmers. The developer places those features into a file. The programmers who use the features include the file in their project.
Now suppose a programmer includes such files from multiple developers. There is a good chance that some of those feature names will conflict with each other. More ominously, each file contains quite a few helper functions and variables whose names give rise to further conflicts.
Clearly, there needs to be some way of hiding implementation details. For many years, JavaScript developers have simulated modules through closures, placing helper functions and classes inside a wrapper function. This is similar to the “hard objects” technique from Chapter 3. They also developed ad-hoc ways of publishing exported features and dependencies.
Node.js implements a module system (called Common.js) that manages module dependencies. When a module is needed, it and its dependencies are loaded. That loading happens synchronously, as soon as the demand for a module occurs.
The AMD (Asynchronous Module Definition) standard defines a system for loading modules asynchronously, which is better suited for browser-based applications.
ECMAScript modules improve on both of these systems. They are parsed to quickly establish their dependencies and exports, without having to execute their bodies first. This allows asynchronous loading and circular dependencies. Nowadays, the JavaScript world is transitioning to the ECMAScript module system.
For Java programmers, an analog of a JavaScript module is a Maven artifact, or, since Java 9, a Java platform module. Artifacts provide dependency information but no encapsulation (beyond that of Java classes and packages). Java platform modules provide both, but they are quite a bit more complex than ECMAScript modules.
Only a few programmers write modules; many more programmers consume them. Let us therefore start with the most common activity: importing features from an existing module.
Most commonly, you import functions and classes. But you can also import objects, arrays, and primitive values.
A module implementor can tag one feature (presumably the most useful one) as the default. The import
syntax makes it particularly easy to import the default feature. Consider this example where we import a class from a module that provides an encryption service:
import CaesarCipher from './modules/caesar.mjs'
This statement specifies the name that you choose to give to the default feature, followed by the file that contains the module implementation. For more details on specifying module locations, see Section 10.7, “Packaging Modules” (page 217).
The choice of the feature name in your program is entirely yours. If you prefer, you can give it a shorter name:
import CC from './modules/caesar.mjs'
If you work with modules that provide their services as a default feature, that is all you need to know about the ECMAScript module system.
In a browser, the module location must be a full URL or a relative URL that starts with ./
, ../
, or /
. This restriction leaves open the possibility of special handling for well-known package names or paths in the future.
In Node.js, you can use a relative URL that starts with ./
, ../
, or a file://
URL. You can also specify a package name.
A module can export named features in addition to, or instead of, the default. The module implementor gives a name to each nondefault feature. You can import as many of these named features as you like.
Here we import two functions that the module calls encrypt
and decrypt
:
import { encrypt, decrypt } from './modules/caesar.mjs'
Of course, there is a potential pitfall. What if you want to import encryption functions from two modules, and they both call it encrypt
? Fortunately, you can rename the imported features:
import { encrypt as caesarEncrypt, decrypt as caesarDecrypt } from './modules/caesar.mjs'
In this way, you can always avoid name clashes.
If you want to import both the default feature and one or more named features, combine the two syntax elements:
import CaesarCipher, { encrypt, decrypt } from './modules/caesar.mjs'
or
import CaesarCipher, { encrypt as caesarEncrypt, decrypt as caesarDecrypt} . . .
Be sure to use braces when importing a single nondefault feature:
import { encrypt } from './modules/caesar.mjs'
Without the braces, you would give a name to the default feature.
If a module exports many names, then it would be tedious to name each of them in the import
statement. Instead, you can pour all exported features into an object:
import * as CaesarCipherTools from './modules/caesar.mjs'
You then use the imported functions as CaesarCipherTools.encrypt
and CaesarCipherTools.decrypt
. If there is a default feature, it is accessible as CaesarCipherTools.default
. You can also name it:
import CaesarCipher, * as CaesarCipherTools . . .
You can use the import
statement without importing anything:
import './app/init.mjs'
Then the statements in the file are executed but nothing is imported. This is not common.
A stage 4 proposal allows you to import a module whose location is not fixed. Loading a module on demand can be useful to reduce the start-up cost and footprint of an application.
For dynamic import, use the import
keyword as if it were a function with the module location as argument:
import(`./plugins/${action}.mjs`)
The dynamic import
statement loads the module asynchronously. The statement yields a promise for an object containing all exported features. The promise is fulfilled when the module is loaded. You can then use its features:
import(`./plugins/${action}.mjs`) .then(module => { module.default() module.namedFeature(args) . . . })
Of course you can use the async
/await
notation:
async load(action) { const module = await import(`./plugins/${action}.mjs`) module.default() module.namedFeature(args) . . . }
When you use a dynamic import, you do not import features by name, and there is no syntax for renaming features.
The import
keyword is not a function, even though it looks like one. It is just given a function-like syntax. This is similar to the super(. . .)
syntax of the super
keyword.
Now that you have seen how to import features from modules, let us switch to the module implementor’s perspective.
In a module, you can tag any number of functions, classes, or variable declarations with export
:
export function encrypt(str, key) { . . . } export class Cipher { . . . } export const DEFAULT_KEY = 3
Alternatively, you can provide an export
statement with the names of the exported features:
function encrypt(str, key) { . . . } class Cipher { . . . } const DEFAULT_KEY = 3 . . . export { encrypt, Cipher, DEFAULT_KEY }
With this form of the export
statement, you can provide different names for exported features:
export { encrypt as caesarEncrypt, Cipher, DEFAULT_KEY }
Keep in mind that the export
statement defines the name under which the feature is exported. As you have seen, an importing module may use the provided name or choose a different name to access the feature.
The exported features must be defined at the top-level scope of the module. You cannot export local functions, classes, or variables.
At most one function or class can be tagged as export default
:
export default class Cipher { . . . }
In this example, the Cipher
class becomes the default feature of the module.
You cannot use export default
with variable declarations. If you want the default export to be a value, do not declare a variable. Simply write export default
followed by the value:
export default 3 // OK export default const DEFAULT_KEY = 3 // Error—export default
not valid withconst
/let
/var
It isn’t likely that someone would make a simple constant the default value. A more realistic choice would be to export an object with multiple features:
export default { encrypt, Cipher, DEFAULT_KEY }
You can use this syntax with an anonymous function or class:
export default (s, key) => { . . . } // No need to name this function
or
export default class { // No need to name this class
encrypt(key) { . . . }
decrypt(key) { . . . }
}
Finally, you can use the renaming syntax to declare the default feature:
export { Cipher as default
}
The default feature is simply a feature with the name default
. However, since default
is a keyword, you cannot use it as an identifier and must use one of the syntactical forms of this section.
Each exported feature is a variable with a name and a value. The value may be a function, a class, or an arbitrary JavaScript value.
The value of an exported feature can change over time. Those changes are visible in importing modules. In other words, an exported feature captures a variable, not just a value.
For example, a logging module might export a variable with the current logging level and a function to change it:
export const Level = { FINE: 1, INFO: 2, WARN: 3, ERROR: 4 } export let currentLevel = Level.INFO export const setLevel = level => { currentLevel = level }
Now consider a module that imports the logging module with the statement:
import * as logging from './modules/logging.mjs'
Initially, in that module, logging.currentLevel
has value Level.INFO
or 2
. If the module calls
logging.setLevel(logging.Level.WARN)
the variable is updated, and logging.currentLevel
has value 3
.
However, in the importing module, the variable is read-only. You cannot set
logging.currentLevel = logging.Level.WARN
// Error—cannot assign to imported variable
The variables holding exported features are created as soon as the module is parsed, but they are only filled when the module body is executed. This enables circular dependencies between modules (see Exercise 6).
If you have a cycle of modules that depends on each other, then it can happen that an exported feature is still undefined
when it is used in another module—see Exercise 11.
When you provide a module with a rich API and a complex implementation, you will likely depend on other modules. Of course, the module system takes care of dependency management, so the module user doesn’t have to worry about that. However, it can happen that one of the modules contains useful features that you want to make available to your users. Instead of asking users to import those features themselves, you can reexport them.
Here, we reexport features from another module:
export { randInt, randDouble } from './modules/random.mjs'
Whoever imports this module will have the features randInt
and randDouble
from the './modules/random.mjs'
module available, as if they had been defined in this module.
If you like, you can rename features that you reexport:
export { randInt as randomInteger } from './modules/random.mjs'
To reexport the default feature of a module, refer to it as default
:
export { default } from './modules/stringutil.mjs' export { default as StringUtil } from './modules/stringutil.mjs'
Conversely, if you want to reexport another feature and make it the default of this module, use the following syntax:
export { Random as default } from './modules/random.mjs'
Finally, you can reexport all nondefault features of another module.
export * from './modules/random.mjs'
You might want to do this if you split up your project into many smaller modules and then provide a single module that is a façade for the smaller ones, reexporting all of them.
The export *
statement skips the default feature because there would be a conflict if you were to reexport default features from multiple modules.
Modules are different from plain “scripts”:
The code inside a module always executes in strict mode.
Each module has its own top-level scope that is distinct from the global scope of the JavaScript runtime.
A module is only processed once even if it is loaded multiple times.
A module is processed asynchronously.
A module can contain import
and export
statements.
When the JavaScript runtime reads the module content, it must know that it is processing a module and not a plain script.
In a browser, you load a module with a script
tag whose type
attribute is module
.
<script type="module" src="./modules/caesar.mjs"></script>
In Node.js, you can use the file extension .mjs
to indicate that a file is a module. If you want to use a plain .js
extension, you need to mark modules in the package.json
configuration file. When invoking the node
executable in interactive mode, use the command-line option --input-type=module
.
It seems simplest to always use the .mjs
extension for modules. All runtimes and build tools recognize that extension.
When you serve .mjs
files from a web server, the server needs to be configured to provide the header Content-Type: text/javascript
with the response.
Unlike regular scripts, browsers fetch modules with CORS restrictions. If you load modules from a different domain, the server must return an Access-Control-Allow-Origin
header.
The import.meta
object is a stage 3 proposal to provide information about the current module. Some JavaScript runtimes provide the URL from which the module was loaded as import.meta.url
.
Find a JavaScript library for statistical computation (such as https://github.com/simple-statistics/simple-statistics
). Write a program that imports the library as an ECMAScript module and computes the mean and standard deviation of a data set.
Find a JavaScript library for encryption (such as https://github.com/brix/crypto-js
). Write a program that imports the library as an ECMAScript module and encrypts a message, then decrypts it.
Implement a simple logging module that supports logging messages whose log level exceeds a given threshold. Export a log function, constants for the log level, and a function to set the threshold.
Repeat the preceding exercise, but export a single class as a default feature.
Implement a simple encryption module that uses the Caesar cipher (adding a constant to each code point). Use the logging module from one of the preceding exercises to log all calls to decrypt
.
As an example of a circular dependency between modules, repeat the preceding exercise, but provide an option to encrypt the logs in the logging module.
Implement a simple module that provides random integers, arrays of random integers, and random strings. Use as many different forms of the export
syntax as you can.
What is the difference between
import Cipher from './modules/caesar.mjs'
and
import { Cipher } from './modules/caesar.mjs'
Explain the difference between
export { encrypt, Cipher, DEFAULT_KEY }
and
export default { encrypt, Cipher, DEFAULT_KEY }
Which of the following are valid JavaScript?
export function default(s, key) { . . . } export default function (s, key) { . . . } export const default = (s, key) => { . . . } export default (s, key) => { . . . }
Trees have two kinds of nodes: those with children (parents) and those without (leaves). Let’s model that with inheritance:
class Node { static from(value, ...children) { return children.length === 0 ? new Leaf(value) : new Parent(value, children) } } class Parent extends Node { constructor(value, children) { super() this.value = value this.children = children } depth() { return 1 + Math.max(...this.children.map(c => c.depth())) } } class Leaf extends Node { constructor(value) { super() this.value = value } depth() { return 1 } }
Now a module-happy developer wants to put each class into a separate module. Do that and try it out with a demo program:
import { Node } from './node.mjs' const myTree = Node.from('Adam', Node.from('Cain', Node.from('Enoch')), Node.from('Abel'), Node.from('Seth', Node.from('Enos'))) console.log(myTree.depth())
What happens? Why?
Of course, the issue in the preceding exercise could have been easily avoided by not using inheritance, or by placing all classes into one module. In a larger system, those alternatives may not be feasible. In this exercise, keep each class in its own module and provide a façade module tree.mjs
that reexports all three modules. In all modules, import from './tree.mjs'
, not the individual modules. Why does this solve the issue?
3.145.179.193