Skip to content

Latest commit

 

History

History
653 lines (479 loc) · 33.4 KB

ch08.asciidoc

File metadata and controls

653 lines (479 loc) · 33.4 KB

JavaScript Modules

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).

CommonJS

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 require statements and when using the node CLI, we should strongly consider getting into the habit of including it nevertheless. Browser implementations of ESM won’t have this luxury, since that’d entail extra roundtrips to figure out the correct endpoint for a JavaScript module HTTP resource.

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 node program in your terminal.

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)
Printing an item for our grocery shopping list
Figure 1. Rendering a model as HTML is as easy as saying template literal expression interpolation!

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)
Printing a grocery shopping list
Figure 2. Composing components made with template literals can be as simple as we choose to make them

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
}]))
Printing different views through a normalized render function.
Figure 3. Creating a bare bones HTML rendering application is made easy by template literals

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.

JavaScript Modules

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.

Strict Mode

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 assuming delete global.prop

  • eval doesn’t introduce new variables into its surrounding scope

  • eval and arguments can’t be bound or assigned to

  • arguments doesn’t magically track changes to method parameters

  • arguments.callee is no longer supported, throws a TypeError

  • arguments.caller is no longer supported, throws a TypeError

  • Context passed as this in method invocations is not "boxed" into an Object

  • No longer able to use fn.caller and fn.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.

export Statements

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.

Exporting a default binding

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.

Named exports

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.

Exporting lists

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.

Bindings, not 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.

Exporting from another module

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.

import Statements

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.

Importing default exports

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.

Importing named exports

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.

Wildcard import statements

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

Dynamic import()

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.

Practical Considerations for ES Modules

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.