HomeArchiveFeedShelf

The Module System of ES6

This article is a translation, and the original text can be found here: https://hacks.mozilla.org/2015/08/es6-in-depth-modules/

ES6 is short for ECMAScript version 6, which is the standard for the new generation of JavaScript. ES6 in Depth is a series of introductions to new features of ES6.

Looking back to 2007, when I started working in Mozilla's JavaScript team, typical JavaScript programs had only one line of code.

Two years later, Google Maps was released. But not long before that, the main use of JavaScript was still form validation, and of course, your <input onchange=> handler averaged only one line of code.

As time has passed, JavaScript projects have become very large, and the community has developed tools to help create scalable applications. The first thing you need is a module system. A module system allows you to spread your work across different files and directories, enabling them to access each other and load them very efficiently. Naturally, JavaScript has developed a module system, in fact, multiple module systems (AMD, CommonJS, CMD, translator's note). Moreover, the community has provided package management tools (NPM, translator's note) that allow you to install and copy software that heavily depends on other modules. You might feel that the ES6 with module features has come a bit late.

Basics of Modules

An ES6 module is a file that contains JS code. There is no so-called module keyword in ES6. A module looks just like a regular script file, with the following two differences:

  • ES6 modules automatically enable strict mode, even if you don't write 'use strict'.
  • You can use import and export in a module.

Let's first look at export. Anything declared in a module is private by default; if you want to make it public to other modules, you must export that part of the code. We have several ways to implement this, the simplest way is to add an export keyword.

// kittydar.js - Find the locations of all the cats in an image.
// (Heather Arthur wrote this library for real)
// (but she didn't use modules, because it was 2013)

export function detectCats(canvas, options) {
  var kittydar = new Kittydar(options);
  return kittydar.detectCats(canvas);
}

export class Kittydar {
  ... several methods doing image processing ...
}

// This helper function isn't exported.
function resizeCanvas() {
  ...
}
...

You can add export before function, class, var, let, or const.

If you want to write a module, that's all you need! No more putting code in IIFE or a callback function. Since your code is a module rather than a script file, everything you declare will be encapsulated within the module's scope, and there will no longer be global variables across modules or files. The parts you export will become the public API of this module.

Besides, the code inside a module is not much different from regular code. It can access some basic global variables like Object and Array. If your module runs in the browser, it will have access to document and XMLHttpRequest.

In another file, we can import this module and use the detectCats() function:

// demo.js - Kittydar demo program

import { detectCats } from 'kittydar.js'

function go() {
  var canvas = document.getElementById('catpix')
  var cats = detectCats(canvas)
  drawRectangles(canvas, cats)
}

To import interfaces from multiple modules, you can write:

import { detectCats, Kittydar } from 'kittydar.js'

When you run a module that contains import statements, the imported modules will be loaded first, and then the contents of each module will be traversed in a depth-first manner according to their dependencies, skipping already executed modules to avoid dependency cycles.

That's the basics of modules, quite simple.

Export Lists

If you find it cumbersome to write export before every part you want to export, you can just write a single line with the list of variables you want to export, wrapped in curly braces.

export {detectCats, Kittydar};

// no `export` keyword required here
function detectCats(canvas, options) { ... }
class Kittydar { ... }

The export list does not have to appear at the top of the file; it can appear on any line in the module's top-level scope. You can write multiple export lists and also include other export statements in the list, as long as no variable names are exported more than once.

Renaming Exports and Imports

If the variable name you import happens to conflict with a variable name in your module, ES6 allows you to rename the imported items:

// suburbia.js

// Both these modules export something named `flip`.
// To import them both, we must rename at least one.
import {flip as flipOmelet} from "eggs.js";
import {flip as flipHouse} from "real-estate.js";
...

Similarly, you can also rename variables when exporting. This feature is very convenient when you want to export the same variable name twice, for example:

// unlicensed_nuclear_accelerator.js - media streaming without drm
// (not a real library, but maybe it should be)

function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

Default Exports

One of the design philosophies of the new standard is to be compatible with existing CommonJS and AMD modules. So if you have a Node project and just ran npm install lodash, your ES6 code can independently import functions from Lodash:

import { each, map } from 'lodash'

each([3, 2, 1], (x) => console.log(x))

However, if you are used to _.each or feel uncomfortable without the _, of course, this way of using Lodash is also fine.

In this case, you can slightly change your import syntax and not use curly braces:

import _ from 'lodash'

This shorthand is equivalent to import {default as _} from "lodash";. All CommonJS and AMD modules have a default export when used with ES6 code, which is the same as the exports object you get in CommonJS with require().

The ES6 module system is designed to allow you to import multiple variables at once. But for existing CommonJS modules, you can only get the default export. For example, at the time of writing this article, to my knowledge, the famous colors module does not specifically support ES6. It is a module composed of multiple CommonJS modules, just like those packages on npm. However, you can still directly import it into your ES6 code.

// ES6 equivalent of `var colors = require("colors/safe");`
import colors from 'colors/safe'

If you want to write your own default export, it's also very simple. There's nothing high-tech about it; it's just like a regular export, except its export name is default. You can use the syntax we introduced earlier:

let myObject = {
  field1: value1,
  field2: value2,
}
export { myObject as default }

Even better:

export default {
  field1: value1,
  field2: value2,
}

The export default keyword can be followed by any value: functions, objects, object literals, anything you can describe.

Module Objects

Sorry, this article has a lot of content, but JavaScript is quite good: for some reasons, all language module systems have a bunch of useless features. Fortunately, we only have one topic to discuss, uh, well, two.

import * as cows from 'cows'

When you import *, what you get is a module namespace object. Its properties are the exports of that module, so if the "cows" module exports a function named moo(), after you import "cows", you can write cows.moo().

Aggregating Modules

Sometimes the main module of a package will import many other modules and then export them in a unified way. To simplify such code, we have an import-and-export shorthand:

// world-foods.js - good stuff from all over

// import "sri-lanka" and re-export some of its exports
export { Tea, Cinnamon } from 'sri-lanka'

// import "equatorial-guinea" and re-export some of its exports
export { Coffee, Cocoa } from 'equatorial-guinea'

// import "singapore" and export ALL of its exports
export * from 'singapore'

This export-from expression is similar to an import-from expression followed by an export. But unlike a real import, it does not add variable bindings for the re-exported variables in your scope. So if you plan to write code in world-foods.js that uses Tea, don't use this shorthand.

If a variable exported from "singapore" happens to conflict with the names of other exported variables, an error will occur here. So you should use export * with caution.

Whew! We've covered the syntax, now let's move on to the interesting part.

What Does import Actually Do

It does nothing, believe it or not.

Oh, you don't seem so easily fooled. Well, do you believe that the standard hardly talks about what import is supposed to do? Do you think that's a good thing or a bad thing?

ES6 leaves the details of module loading entirely to the implementation, while the rest of the execution part is specified very precisely.

Generally speaking, when the JS engine runs a module, its behavior can be summarized in the following four steps:

  1. Parsing: The engine implementation reads the module's source code and checks for syntax errors.
  2. Loading: The engine implementation recursively loads all the imported modules. This part has not been standardized yet.
  3. Linking: The engine implementation creates a scope for each newly loaded module and fills it with the bindings of the declarations in the module, including those imported from other modules.

When you try import {cake} from "paleo" but the "paleo" module does not export anything called cake, you will also get an error at this point. This is unfortunate because you are just one step away from successfully executing and tasting cake!

  1. Execution: Finally, the JS engine starts executing the code in the newly loaded module. By this time, the processing of import has already been completed, so when the JS engine executes a line with an import statement, it does nothing.Did you see that? I said import "does nothing", and I didn't lie to you, did I? When it comes to serious topics in programming languages, I never tell lies.

However, now we can introduce the interesting parts of this system, which is a very cool trick. Because the specification does not specify the details of loading, and because you can figure out the module dependencies just by looking at the import statements in the source code before running, some ES6 implementations can even complete all the work through preprocessing, then bundle all modules into a single file, and finally distribute it over the network. Tools like webpack do just that.

This is remarkable because loading resources over the network is very time-consuming. Suppose you request a resource, then find that it has import statements, and then you have to request more resources, which will take even more time. A naive loader implementation might initiate many network requests. But with webpack, you can not only start using ES6 today, but also gain all the benefits of modularity without compromising runtime performance.

We originally planned a detailed definition of the ES6 module loading specification, and we did create one. One reason it did not become the final standard is that it could not harmonize with the feature of bundling. The module system needs to be standardized, and bundling should not be abandoned because it is too good.

Dynamic vs Static, or: Rules and How to Break Them

As a dynamic programming language, JavaScript surprisingly has a static module system.

  • import and export can only be written in the top-level scope. You cannot use imports and exports in conditional statements, nor can you use import in the scope of a function you write.
  • All exports must explicitly specify a variable name, and you cannot dynamically import a bunch of variables through a loop.
  • The module object is encapsulated, and we cannot hack a new feature through polyfill.
  • Before the module code runs, all modules must go through the processes of loading, parsing, and linking. There is no syntax for lazy loading or lazy import.
  • For import errors, you cannot recover at runtime. An application may contain hundreds of modules, and if any one of them fails to load or link, the application will not run. You cannot import in a try/catch statement. (However, because the ES6 module system is so static, webpack can detect these errors for you during preprocessing.)
  • You cannot hook a module and run some of your code before it is loaded. This means that modules cannot control how their dependencies are loaded.

As long as your needs are static, this module system is still quite nice. But you still want to hack a bit, right?

That's why the module loading system you use may provide an API. For example, webpack has an API that allows you to "code splitting" and lazily load modules according to your needs. This API can also help you break all the rules listed above.

ES6 modules are very static, which is great—many powerful compiler tools benefit from this. Moreover, the static syntax has been designed to work in conjunction with dynamic, programmable loader APIs.

When Can I Start Using ES6 Modules?

If you want to start using them today, you need preprocessing tools like Traceur and Babel. Previous articles in this series have also introduced how to use Babel and Broccoli to generate ES6 code for the web. The examples from that article have also been open-sourced on GitHub. My article also discusses how to use Babel and webpack.

The main designers of the ES6 module system are Dave Herman and Sam Tobin-Hochstadt, who insisted on the static parts of the ES6 module system you see today, despite opposition from several committee members, including myself, and argued for years. Jon Coppeard is implementing ES6 modules in Firefox. Work is ongoing, including the JavaScript Loader Specification. HTML elements like <script type=module> will also be introduced later.

That's ES6 for you.

Feel free to criticize ES6, and look forward to next week's summary article in the ES6 in Depth series.

@2015-08-18 19:43