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”.
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).
//------ lib.js ------
var mutableValue = 3;
function incMutableValue() {
mutableValue++;
}
module.exports = {
mutableValue: mutableValue, // (A)
incMutableValue: incMutableValue,
};
//------ main1.js ------
var mutableValue = require('./lib').mutableValue; // (B)
var incMutableValue = require('./lib').incMutableValue;
// The imported value is a (disconnected) copy of a copy
console.log(mutableValue); // 3
incMutableValue();
console.log(mutableValue); // 3
// The imported value can be changed
mutableValue++;
console.log(mutableValue); // 4
If you access the value via the exports object, it is still copied once, on export:
//------ main2.js ------
var lib = require('./lib');
// The imported value is a (disconnected) copy
console.log(lib.mutableValue); // 3
lib.incMutableValue();
console.log(lib.mutableValue); // 3
// The imported value can be changed
lib.mutableValue++;
console.log(lib.mutableValue); // 4
In contrast to CommonJS modules, ES6 modules export bindings, live connections to values. The following code demonstrates how that works:
//------ lib.js ------
export let mutableValue = 3;
export function incMutableValue() {
mutableValue++;
}
//------ main1.js ------
import { mutableValue, incMutableValue } from './lib';
// The imported value is live
console.log(mutableValue); // 3
incMutableValue();
console.log(mutableValue); // 4
// The imported value can’t be changed
mutableValue++; // TypeError
If you import the module object via the asterisk (*
), you get similar results:
//------ main2.js ------
import * as lib from './lib';
// The imported value is live
console.log(lib.mutableValue); // 3
lib.incMutableValue();
console.log(lib.mutableValue); // 4
// The imported value can’t be changed
lib.mutableValue++; // TypeError
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:
//------ a.js ------
import {bar} from 'b';
export function foo() {
bar();
}
//------ b.js ------
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.
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:
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' |
function foo() {}
export { foo };
foo
foo
function foo() {}
export { foo as bar };
foo
bar
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:
foo
foo
There are two kinds of default exports:
The following code default-exports the result of the expression 123
:
export default 123;
It is equivalent to:
const *default* = 123; // *not* legal JavaScript
export { *default* as default };
If you default-export an expression, you get:
*default*
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.
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:
foo
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*() {} // *not* legal JavaScript
export { *default* as default };
The names are:
*default*
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 |
This section gives pointers into the ECMAScript 2015 (ES6) language specification.
Managing imported bindings:
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”.
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.