CommonJS modules export values, while ES6 modules export immutable bindings. This blog post explains what that means.

You should be loosely familiar with ES6 modules. If you aren’t, you can consult the chapter on modules in “Exploring ES6”.

CommonJS modules export values #

With CommonJS (Node.js) modules, things work in relatively familiar ways.

If you import a value into a variable, the value is copied twice: once when it is exported (line A) and once it is imported (line B).

var mutableValue = 3 ; function incMutableValue ( ) { mutableValue++; } module .exports = { mutableValue : mutableValue, incMutableValue: incMutableValue, }; var mutableValue = require ( './lib' ).mutableValue; var incMutableValue = require ( './lib' ).incMutableValue; console .log(mutableValue); incMutableValue(); console .log(mutableValue); mutableValue++; console .log(mutableValue);

If you access the value via the exports object, it is still copied once, on export:

var lib = require ( './lib' ); console .log(lib.mutableValue); lib.incMutableValue(); console .log(lib.mutableValue); lib.mutableValue++; console .log(lib.mutableValue);

ES6 modules export immutable bindings #

In contrast to CommonJS modules, ES6 modules export bindings, live connections to values. The following code demonstrates how that works:

export let mutableValue = 3 ; export function incMutableValue ( ) { mutableValue++; } import { mutableValue, incMutableValue } from './lib' ; console .log(mutableValue); incMutableValue(); console .log(mutableValue); mutableValue++;

If you import the module object via the asterisk ( * ), you get similar results:

import * as lib from './lib' ; console .log(lib.mutableValue); lib.incMutableValue(); console .log(lib.mutableValue); lib.mutableValue++;

Why export bindings? #

Given that exporting bindings is different from how data is normally transported in JavaScript – why do it this way? It has the benefit of making it easier to deal with cyclic dependencies. The following code is an example of a cyclic dependency:

import {bar} from 'b' ; export function foo ( ) { bar(); } import {foo} from 'a' ; export function bar ( ) { if ( Math .random()) foo(); }

a.js imports bar from b.js , which means that b.js is executed before a.js . But how can b.js access foo then, if a.js hasn’t provided a value for it, yet? b.js imports a binding, which initially refers to an empty slot. Once a.js is executed, it fills in that slot. Therefore, b.js only has a problem if it uses foo in the top level of its body, while it is executed. Using foo in entities that are accessed after the evaluation of a.js are fine. One such entity is the function bar() .

This may seem like an theoretical exercise, but cyclic dependencies can happen relatively easily in large code bases, especially during refactoring. Cycles tend to be longer (for example: m1 imports m2 imports m3 imports m4 imports m1 ), but the problem is the same.

Consult the section “Cyclic dependencies in CommonJS” in “Exploring ES6” to find out how cyclic dependencies are handled in CommonJS.

Exporting bindings #

How are bindings handled by JavaScript? Exports are managed via the data structure export entry. All export entries (except those for re-exports) have the following two names:

Local name: is the name under which the export is stored inside the module.

Export name: is the name that importing modules need to use to access the export.

After you have imported an entity, that entity is always accessed via a pointer that has the two components module and local name. In other words, that pointer refers to a binding inside a module.

Let’s examine the export names and local names created by various kinds of exporting. The following table (adapted from the ES6 spec) gives an overview, subsequent sections have more details.

Statement Local name Export name export {v}; 'v' 'v' export {v as x}; 'v' 'x' export let v = 123; 'v' 'v' export function f() {} 'f' 'f' export default function f() {} 'f' 'default' export default function () {} '*default*' 'default' export default 123; '*default*' 'default'

Export clause #

function foo ( ) {} export { foo };

Local name: foo

Export name: foo

function foo ( ) {} export { foo as bar };

Local name: foo

Export name: bar

Inline exports #

This is an inline export:

export function foo ( ) {}

It is equivalent to the following code:

function foo ( ) {} export { foo };

Therefore, we have the following names:

Local name: foo

Export name: foo

Default exports #

There are two kinds of default exports:

Default exports of hoistable declarations (function declarations, generator declarations) and class declarations are similar to normal inline exports in that named local entities are created and tagged.

All other default exports are about exporting the results of expressions.

The following code default-exports the result of the expression 123 :

export default 123 ;

It is equivalent to:

const * default * = 123 ; export { * default * as default };

If you default-export an expression, you get:

Local name: *default*

Export name: default

The local name was chosen so that is wouldn’t clash with any other local name.

Note that a default export still leads to a binding being exported. But, due to *default* not being a legal identifier, you can’t access that binding from inside the module.

Default-exporting hoistable declarations and class declarations #

The following code default-exports a function declaration:

export default function foo ( ) {}

It is equivalent to:

function foo ( ) {} export { foo as default };

The names are:

Local name: foo

Export name: default

That means that you can change the value of the default export from within the module, by assigning a different value to foo .

(Only) for default exports, you can also omit the name of a function declaration:

export default function ( ) {}

That is very similar to default-exporting an expression and therefore equivalent to:

function * default *( ) {} export { * default * as default };

The names are:

Local name: *default*

Export name: default

Default-exporting generator declarations and class declarations works similarly to default-exporting function declarations.

Re-exports are handled differently from normal exports. A re-export does not have a local name, it refers to the re-exported entity via that entity’s module and export name (shown in the column “Import name” below).

Statement Module Import name Export name export {v} from 'mod'; 'mod' 'v' 'v' export {v as x} from 'mod'; 'mod' 'v' 'x' export * from 'mod'; 'mod' '*' null

Exported bindings in the spec #

This section gives pointers into the ECMAScript 2015 (ES6) language specification.

Managing imported bindings:

CreateImportBinding () creates local bindings for imports.

GetBindingValue() is used to access them.

ModuleDeclarationInstantiation() sets up the environment of a module (compare: FunctionDeclarationInstantiation(), BlockDeclarationInstantiation()).

The export names and local names created by the various kinds of exports are shown in table 42 in the section “Source Text Module Records”. The section “Static Semantics: ExportEntries” has more details. You can see that export entries are set up statically (before evaluating the module), evaluating export statements is described in the section “Runtime Semantics: Evaluation”.

Be careful with ES6 transpilers #

ES6 transpilers compile ES6 modules to ES5. Due to the completely new way of passing on data (via bindings), you should expect the ES5 version to not always be completely compliant with the ES6 spec. Things are even trickier when transpiled ES6 code has to interoperate with native CommonJS or AMD modules.

That being said, Babel hews pretty close to the spec, as you can see in the GitHub repository for this blog post.