Over the years, we’ve seen multiple different ways in which to split code into more manageable units. For the longest time we’ve had the module pattern, where you simply wrapped pieces of code in self-invoking function expressions. You had to be careful to sort your scripts so that each script came after all of its dependencies.
A while later, the RequireJS library was born. It provided a way of defining the dependencies of each module programmatically, so that a dependency graph is created and you wouldn’t have to worry about sorting your scripts anymore. RequireJS demands that you provide an array of strings used to identify your dependencies and also wrap modules in a function call, which would then receive those dependencies as parameters. Many other libraries provide similar functionality but offer a slightly different API.
Other complexity management mechanisms exist, such as the dependency injection mechanism in AngularJS, where you define named components using functions where you can, in turn, specify other named component dependencies. AngularJS carries the load of dependency injection on your behalf, so you only have to name components and specify dependencies.
CommonJS (CJS) surfaced as an alternative to RequireJS, and it was swiftly popularized by Node.js soon afterwards. In this chapter we’ll take a look at CommonJS, which is still heavily in use today. We’ll then cover the module system introduced to native JavaScript in ES6, and lastly we’ll explore interoperability between CommonJS and native JavaScript modules—also known as ECMAScript modules (ESM).
Unlike other module formats where modules are declared programmatically, in CommonJS every file is a module. CommonJS modules have an implicit local scope, while the global
scope needs to be accessed explicitly. CommonJS modules can dynamically export a public interface consumers can interact with. CommonJS modules import their dependencies dynamically as well, resolving dependencies through require
function calls. These require
function calls are synchronous and return the interface exposed by required modules.
Interpreting the definition of a module format without looking at some code can be confusing. The following code snippet shows what a reusable CommonJS module file may look like. Both the has
and union
functions are local to our module’s scope. Given that we’ve assigned union
to module.exports
, that’ll be the public API for our module.
function has(list, item) {
return list.includes(item)
}
function union(list, item) {
if (has(list, item)) {
return list
}
return [...list, item]
}
module.exports = union
Suppose we take that snippet of code and save it as union.js. We can now consume union.js in another CommonJS module. Let’s call that one app.js. In order to consume union.js, we call require
passing in a relative path to the union.js file.
const union = require('./union.js')
console.log(union([1, 2], 3))
// <- [1, 2, 3]
console.log(union([1, 2], 2))
// <- [1, 2]
Warning
|
We can omit the file extension as long as it’s .js or .json, but this is discouraged. While the file extension is optional for |
We could run app.js
in its current state through the CLI for Node.js, node
, as seen in the next snippet.
» node app.js
# [1, 2, 3]
# [1, 2]
Note
|
After installing Node.js, you’ll be able to use the |
The require
function in CJS can be treated dynamically, just like any other JavaScript function. This aspect of require
is sometimes leveraged to dynamically require
different modules that conform to one interface. As an example, let’s conjure up a templates directory with a number of view template functions. Our templates will take a model and return an HTML string.
The template found in the following code snippet renders an item of a grocery shopping list by reading its attributes from a model
object.
// views/item.js
module.exports = model => `<li>
<span>${ model.amount }</span>
<span>x </span>
<span>${ model.name }</span>
</li>`
Our application could print a <li>
by leveraging the item.js view template.
// app.js
const renderItem = require('./views/item.js')
const html = renderItem({
name: 'Banana bread',
amount: 3
})
console.log(html)
Rendering a model as HTML is as easy as saying template literal expression interpolation! shows our tiny application in action.
The next template we’ll make renders the grocery list itself. It receives an array of items, and renders each of them by reusing the item.js template from the previous code snippet.
// views/list.js
const renderItem = require('./item.js')
module.exports = model => `<ul>
${ model.map(renderItem).join('\n') }
</ul>`
We can consume the list.js template in a very similar way to what we did before, but we’ll need to adjust the model passed into the template so that we provide a collection of items instead of a single one.
// app.js
const renderList = require('./views/list.js')
const html = renderList([{
name: 'Banana bread',
amount: 3
}, {
name: 'Chocolate chip muffin',
amount: 2
}])
console.log(html)
Composing components made with template literals can be as simple as we choose to make them shows our updated application in all its glory.
In the examples so far, we’ve written short modules that are only concerned with producing an HTML view after matching a model
object with the corresponding view template. A simple API encourages reusability, which is why we’re easily able to render the items for a list by mapping their models to the item.js templating function, and joining their HTML representations with newlines.
Given that the views all have a similar API where they take a model and return an HTML string, we can treat them uniformly. If we wanted a render
function that could render any template, we could easily do that, thanks to the dynamic nature of require
. The next example shows how we can construct the path to a template module. An important distinction is how require
calls don’t necessarily need to be on the top level of a module. Calls to require
can be anywhere, even embedded within other functions.
// render.js
module.exports = function render(template, model) {
return require(`./views/${ template }`.js)(model)
}
Once we had such an API, we wouldn’t have to worry about carefully constructing require
statements that match the directory structure of our view templates, because the render.js module could take care of that. Rendering any template becomes a matter of calling the render
function with the template’s name and the model for that template, as demonstrated in the following code and Creating a bare bones HTML rendering application is made easy by template literals.
// app.js
const render = require('./render.js')
console.log(render('item', {
name: 'Banana bread',
amount: 1
}))
console.log(render('list', [{
name: 'Apple pie',
amount: 2
}, {
name: 'Roasted almond',
amount: 25
}]))
Moving on, you’ll notice that ES6 modules are somewhat influenced by CommonJS. In the next few sections we’ll look at export
and import
statements, and learn how ESM is compatible with CJS.
As we explored the CommonJS module system, you might’ve noticed how the API is simple but powerful and flexible. ES6 modules offer an even simpler API that’s almost as powerful at the expense of some flexibility.
In the ES6 module system, strict mode is turned on by default. Strict mode is a featureRead this comprehensive article about strict mode on Mozilla's MDN. that disallows bad parts of the language, and turns some silent errors into loud exceptions being thrown. Taking into account these disallowed features, compilers can enable optimizations, making JavaScript runtime faster and safer.
-
Variables must be declared
-
Function parameters must have unique names
-
Using
with
statements is forbidden -
Assignment to read-only properties results in errors being thrown
-
Octal numbers like
00740
are syntax errors -
Attempts to
delete
undeletable properties throw an error -
delete prop
is a syntax error, instead of assumingdelete global.prop
-
eval
doesn’t introduce new variables into its surrounding scope -
eval
andarguments
can’t be bound or assigned to -
arguments
doesn’t magically track changes to method parameters -
arguments.callee
is no longer supported, throws aTypeError
-
arguments.caller
is no longer supported, throws aTypeError
-
Context passed as
this
in method invocations is not "boxed" into anObject
-
No longer able to use
fn.caller
andfn.arguments
to access the JavaScript stack -
Reserved words (e.g.,
protected
,static
,interface
, etc.) cannot be bound
Let’s now dive into the export
statement.
In CommonJS modules, you export values by exposing them on module.exports
. You can expose anything from a value type to an object, an array, or a function, as seen in the next few code snippets.
module.exports = 'hello'
module.exports = { hello: 'world' }
module.exports = ['hello', 'world']
module.exports = function hello() {}
ES6 modules are files that may expose an API through export
statements. Declarations in ESM are scoped to the local module, just like we observed about CommonJS. Any variables declared inside a module aren’t available to other modules unless they’re explicitly exported as part of that module’s API and then imported in the module that wants to access them.
You can mimic the CommonJS code we just saw by replacing module.exports =
with export default
statements.
export default 'hello'
export default { hello: 'world' }
export default ['hello', 'world']
export default function hello() {}
In CommonJS, module.exports
can be assigned-to dynamically.
function initialize() {
module.exports = 'hello!'
}
initialize()
In contrast with CJS, export
statements in ESM can only be placed at the top level. "Top-level only" export
statements is a good constraint to have, as there aren’t many good reasons to dynamically define and expose an API based on method calls. This limitation also helps compilers and static analysis tools parse ES6 modules.
function initialize() {
export default 'hello!' // SyntaxError
}
initialize()
There are a few other ways of exposing an API in ESM, besides export default
statements.
When you want to expose multiple values from CJS modules you don’t necessarily need to explicitly export an object containing every one of those values. You could simply add properties onto the implicit module.exports
object. There’s still a single binding being exported, containing all properties the module.exports
object ends up holding. While the following example exports two individual values, both are exposed as properties on the exported object.
module.exports.counter = 0
module.exports.count = () => module.exports.counter++
We can replicate this behavior in ESM by using the named exports syntax. Instead of assigning properties to an implicit module.exports
object like with CommonJS, in ES6 you declare the bindings you want to export
, as shown in the following code snippet.
export let counter = 0
export const count = () => counter++
Note that the last bit of code cannot be refactored to extract the variable declarations into standalone statements that are later passed to export
as a named export, as that’d be a syntax error.
let counter = 0
const count = () => counter++
export counter // SyntaxError
export count
By being rigid in how its declarative module syntax works, ESM favors static analysis, once again at the expense of flexibility. Flexibility inevitably comes at the cost of added complexity, which is a good reason not to offer flexible interfaces.
ES6 modules let you export
lists of named top-level members, as seen in the following snippet. The syntax for export lists is easy to parse, and presents a solution to the problem we observed in the last code snippet from the previous section.
let counter = 0
const count = () => counter++
export { counter, count }
If you’d like to export a binding but give it a different name, you can use the aliasing syntax: export { count as increment }
. In doing so, we’re exposing the count
binding from the local scope as a public method under the increment
alias, as the following snippet shows.
let counter = 0
const count = () => counter++
export { counter, count as increment }
Finally, we can specify a default export when using the named member list syntax. The next bit of code uses as default
to define a default export at the same time as we’re enumerating named exports.
let counter = 0
const count = () => counter++
export { counter as default, count as increment }
The following piece of code is equivalent to the previous one, albeit a tad more verbose.
let counter = 0
const count = () => counter++
export default counter
export { count as increment }
It’s important to keep in mind that we are exporting bindings, and not merely values.
ES6 modules export bindings, not values or references. This means that a fungible
binding exported from a module would be bound into the fungible
variable on the module, and its value would be subject to changes made to fungible
. While unexpectedly changing the public interface of a module after it has initially loaded can lead to confusion, this can indeed be useful in some cases.
In the next code snippet, our module’s fungible
export would be initially bound to an object and be changed into an array after five seconds.
export let fungible = { name: 'bound' }
setTimeout(() => fungible = [0, 1, 2], 5000)
Modules consuming this API would see the fungible
value changing after five seconds. Consider the following example, where we print the consumed binding every two seconds.
import { fungible } from './fungible.js'
console.log(fungible) // <- { name: 'bound' }
setInterval(() => console.log(fungible), 2000)
// <- { name: 'bound' }
// <- { name: 'bound' }
// <- [0, 1, 2]
// <- [0, 1, 2]
// <- [0, 1, 2]
This kind of behavior is best suited for counters and flags, but is best avoided unless its purpose is clearly defined, since it can lead to confusing behavior and API surfaces changing unexpectedly from the point of view of a consumer.
The JavaScript module system also offers an export..from
syntax, where you can expose another module’s interface.
We can expose another module’s named exports using by adding a from
clause to an export
statement. The bindings are not imported into the local scope: our module acts as a pass-through where we expose another module’s bindings without getting direct access to them.
export { increment } from './counter.js'
increment()
// ReferenceError: increment is not defined
You can give aliases to named exports, as they pass through your module. If the module in the following example were named aliased
, then consumers could import { add } from './aliased.js'
to get a reference to the increment
binding from the counter
module.
export { increment as add } from './counter.js'
An ESM module could also expose every single named export found in another module by using a wildcard, as shown in the next snippet. Note that this wouldn’t include the default binding exported by the counter
module.
export * from './counter.js'
When we want to expose another module’s default
binding, we’ll have to use the named export syntax adding an alias.
export { default as counter } from './counter.js'
We’ve now covered every way in which we can expose an API in ES6 modules. Let’s jump over to import
statements, which can be used to consume other modules.
We can load a module from another one using import
statements. The way modules are loaded is implementation-specific; that is, it’s not defined by the specification. We can write spec-compliant ES6 code today while smart people figure out how to deal with module loading in browsers.
Compilers like Babel are able to concatenate modules with the aid of a module system like CommonJS. That means import
statements in Babel mostly follow the same semantics as require
statements in CommonJS.
Let’s suppose we have the following code snippet in a ./counter.js module.
let counter = 0
const increment = () => counter++
const decrement = () => counter--
export { counter as default, increment, decrement }
The statement in the following code snippet could be used to load the counter
module into our app
module. It won’t create any variables in the app
scope. It will execute any code in the top level of the counter
module, though, including that module’s own import
statements.
import './counter.js'
In the same fashion as export
statements, import
statements are only allowed in the top level of your module definitions. This limitation helps compilers simplify their module loading capabilities, as well as help other static analysis tools parse your codebase.
CommonJS modules let you import other modules using require
statements. When we need a reference to the default export, all we’d have to do is assign that to a variable.
const counter = require('./counter.js')
To import the default binding exported from an ES6 module, we’ll have to give it a name. The syntax and semantics are a bit different than what we use when declaring a variable, because we’re importing a binding and not just assigning values to variables. This distinction also makes it easier for static analysis tools and compilers to parse our code.
import counter from './counter.js'
console.log(counter)
// <- 0
Besides default exports, you could also import named exports and alias them.
The following bit of code shows how we can import the increment
method from our counter
module. Reminiscent of assignment destructuring, the syntax for importing named exports is wrapped in braces.
import { increment } from './counter.js'
To import multiple bindings, we separate them using commas.
import { increment, decrement } from './counter.js'
The syntax and semantics are subtly different from destructuring. While destructuring relies on colons to create aliases, import
statements use an as
keyword, mirroring the syntax in export
statements. The following statement imports the increment
method as add
.
import { increment as add } from './counter.js'
You can combine a default export with named exports by separating them with a comma.
import counter, { increment } from './counter.js'
You can also explicitly name the default
binding, which needs an alias.
import { default as counter, increment } from './counter.js'
The following example demonstrates how ESM semantics differ from those of CJS. Remember: we’re exporting and importing bindings, and not direct references. For practical purposes, you can think of the counter
binding found in the next example as a property getter that reaches into the counter
module and returns its local counter
variable.
import counter, { increment } from './counter.js'
console.log(counter) // <- 0
increment()
console.log(counter) // <- 1
increment()
console.log(counter) // <- 2
Lastly, there are also namespace imports.
We can import the namespace object for a module by using a wildcard. Instead of importing the named exports or the default value, it imports everything at once. Note that the *
must be followed by an alias where all the bindings will be placed. If there was a default
export, it’ll be placed in the namespace binding as well.
import * as counter from './counter.js'
counter.increment()
counter.increment()
console.log(counter.default) // <- 2
At the time of this writing, a proposal for dynamic import()Check out the proposal specification draft. expressions is sitting at stage 3 of the TC39 proposal review process. Unlike import
statements, which are statically analyzed and linked, import()
loads modules at runtime, returning a promise for the module namespace object after fetching, parsing, and executing the requested module and all of its dependencies.
The module specifier can be any string, like with import
statements. Keep in mind import
statements only allow statically defined plain string literals as module specifiers. In contrast, we’re able to use template literals or any valid JavaScript expression to produce the module specifier string for import()
function calls.
Imagine you’re looking to internationalize an application based on the language provided by user agents. You might statically import a localizationService
, and then dynamically import the localized data for a given language using import()
and a module specifier built using a template literal that interpolates navigator.language
, as shown in the following example.
import localizationService from './localizationService.js'
import(`./localizations/${ navigator.language }.json`)
.then(module => localizationService.use(module))
Note that writing code like this is generally a bad idea for a number of reasons:
-
It can be challenging to statically analyze, given that static analysis is executed at build time, when it can be hard or impossible to infer the value of interpolations such as
${ navigator.language }
. -
It can’t be packaged up as easily by JavaScript bundlers, meaning the module would probably be loaded asynchronously while the bulk of our application has been loaded.
-
It can’t be tree-shaken by tools like Rollup, which can be used to remove module code that’s never imported anywhere in the codebase—and thus never used—reducing bundle size and improving performance.
-
It can’t be linted by
eslint-plugin-import
or similar tools that help identify module import statements where the imported module file doesn’t exist.
Just like with import
statements, the mechanism for retrieving the module is unspecified and left up to the host environment.
The proposal does specify that once the module is resolved, the promise should fulfill with its namespace object. It also specifies that whenever an error results in the module failing to load, the promise should be rejected.
This allows for loading noncritical modules asynchronously, without blocking page load, and being able to gracefully handle failure scenarios when such a module fails to load, as demonstrated next.
import('./vendor/jquery.js')
.then($ => {
// use jquery
})
.catch(() => {
// failed to load jquery
})
We could load multiple modules asynchronously using Promise.all
. The following example imports three modules and then leverages destructuring to reference them directly in the .then
clause.
const specifiers = [
'./vendor/jquery.js',
'./vendor/backbone.js',
'./lib/util.js'
]
Promise
.all(specifiers.map(specifier => import(specifier)))
.then(([$, backbone, util]) => {
// use modules
})
In a similar fashion, you could load modules using synchronous loops or even async
/await
, as demonstrated next.
async function load() {
const { map } = await import('./vendor/jquery.js')
const $ = await import('./vendor/jquery.js')
const response = await fetch('/cats')
const cats = await response.json()
$('<div>')
.addClass('container cats')
.html(map(cats, cat => cat.htmlSnippet))
.appendTo(document.body)
}
load()
Using await import()
makes dynamic module loading look and feel like static import
statements. We need to watch out and remind ourselves that the modules are asynchronously loaded one by one, though.
Keep in mind that import
is function-like, but it has different semantics from regular functions: import
is not a function definition, it can’t be extended, it can’t be assigned properties, and it can’t be destructured. In this sense, import()
falls in a similar category as the super()
call that’s available in class constructors.
When using a module system, any module system, we gain the ability of explicitly publishing an API while keeping everything that doesn’t need to be public in the local scope. Perfect information hiding like this is a sought-out feature that was previously hard to reproduce: you’d have to rely on deep knowledge of JavaScript scoping rules, or blindly follow a pattern inside which you could hide information, as shown next. In this case, we create a random
module with a locally scoped calc
function, which computes a random number in the [0, n)
range; and a public API with the range
method, which computes a random number in the [min, max]
range.
const random = (function() {
const calc = n => Math.floor(Math.random() * n)
const range = (max = 1, min = 0) => calc(max + 1 - min) + min
return { range }
})()
Compare that to the following piece of code, used in an ESM module called random
. The Immediately Invoked Function Expression (IIFE) wrapper trick went away, along with the name for our module, which now resides in its filename. We’ve regained the simplicity from back in the day, when we wrote raw JavaScript inside plain HTML <script>
tags.
const calc = n => Math.floor(Math.random() * n)
const range = (max = 1, min = 0) => calc(max + 1 - min) + min
export { range }
While we don’t have the problem of having to wrap our modules in an IIFE anymore, we still have to be careful about how we define, test, document, and use each module.
Deciding what constitutes a module is difficult. A lot of factors come into play, some of which I’ve outlined in the form of questions below:
-
Is it highly complex?
-
Is it too large?
-
How well-defined is its API?
-
Is said API properly documented?
-
Is it easy to write tests for the module?
-
How hard is it to add new features?
-
Is it difficult to remove existing functionality?
Complexity is a more powerful metric to track than length. A module can be several thousand lines long but simple, such as a dictionary that maps identifiers to localized strings in a particular language; or it could be a couple dozen lines long but very hard to reason about, such as a data model that also includes domain validation and business logic rules. Complexity can be mitigated by splitting our code up into smaller modules that are only concerned with one aspect of the problem we’re trying to solve. As long as they’re not highly complex, large modules are not as much of an issue.
Having a well-defined API that’s also properly documented is a key aspect of effective modular application design. A module’s API should be focused, and follow information hiding principles. That is: only reveal what is necessary for consumers to interact with it. By not exposing internal aspects of a module, which may be undocumented and prone to change, we keep a simple interface overall and avoid unintended usage patterns. By documenting the public API, even if it’s documented in code or self-documenting, we reduce the barrier of entry for humans looking to utilize the module.
Tests should only be written against the public interface to a module, while its internals must be treated as uninteresting implementation details. Tests need to cover the different aspects of a module’s public interface, but changes to the internal implementation shouldn’t break our test coverage as long as the API remains the same in terms of inputs and outputs.
Ease of adding or removing functionality from a module is yet another useful metric:
-
How hard would it be to add a new feature?
-
Do you have to edit several different modules in order to implement something?
-
Is this a repetitive process? Maybe you could abstract those changes behind a higher-level module that hides that complexity, or maybe doing so would mostly add indirection and make following the codebase harder to read, but with little added benefit or justification.
-
From the other end of the spectrum, how deeply entrenched is the API?
-
Would it be easy to remove a portion of the module, delete it entirely, or even replace it with something else?
-
If modules become too co-dependent, then it can be hard to make edits as the codebase ages, mutates, and grows in size.
We’ll plunge deeper into proper module design, effective module interaction, and module testing over the next three books in this series.
Browsers are only scratching the surface of native JavaScript modules. At the time of this writing, some browsers already implement import
and export
statements. Some browsers have already implemented <script type='module'>
, enabling them to consume modules when specifying the module
script type. The module loader specification isn’t finalized yet, and you can track its current status.
Meanwhile, Node.js hasn’t yet shipped a working implementation of the JavaScript module system. Given that JavaScript ecosystem tooling relies on node, it’s not yet clear how cross-compatibility will be attained. The dilemma of how to know whether a file is written in CJS or ESM is what’s delaying a working implementation. A proposal to infer whether a file was ESM based on the presence of at least one import
or export
statement was abandoned, and it seems like the current course of action is to introduce a new file extension specifically tailored toward ESM modules. There is quite a bit of nuance given the variety of use cases and platforms Node.js runs on, making it tough to arrive at a solution that remains elegant, performant, and correct for every use case.
With that said, let’s turn over to the last chapter, on leveraging all of these new language features and syntax effectively.