A proposed “spec mode” for Babel makes transpiled ES modules more spec-compliant. That’s a crucial step in preparing for native ES modules. You’ll also learn how ES modules and CommonJS modules will interoperate on Node.js and how far along ES module support is on browsers and Node.js.
Update 2017-05-08: follow-up blog post:
At the moment, the main way to use ES modules on Node.js and browsers is to transpile them to CommonJS modules via Babel.
The benefit of this approach is that integration with the CommonJS ecosystem, including npm modules, is seamless.
On the flip side, the code that Babel currently generates does not comply with the ECMAScript specification. That is a problem, because code that works with Babel now, won’t work as native modules.
That’s why Diogo Franco has created a pull request that adds a so-called “spec mode” to transform-es2015-modules-commonjs
. Modules transpiled in this mode conform as closely to the spec as is possible without using ES6 proxies. The downside is that the only way to access normal (untranspiled) CommonJS modules is via a default import.
Spec mode is switched on like this:
{
"presets": [
["es2015", { "modules": false }]
],
"plugins": [
["transform-es2015-modules-commonjs", {
"spec": true
}]
]
}
In this section, I explain where current transpilation deviates from ES module semantics and how the spec mode fixes that.
In an ES module, the imports are live views of the exported values. Babel simulates that in CommonJS in two steps.
Step 1: keep variables and exports in sync. Whenever you update an exported variable foo
, Babel currently also updates the corresponding property exports.foo
, as you can see in lines A, B and C.
// Input
export let foo = 1;
foo = 2;
function bar() {
foo++;
}
// Output
Object.defineProperty(exports, "__esModule", {
value: true
});
var foo = exports.foo = 1; // (A)
exports.foo = foo = 2; // (B)
function bar() {
exports.foo = foo += 1; // (C)
}
The marker property __esModule
lets importing modules know that this is a transpiled ES module (which matters especially for default exports).
Spec mode stays much closer to the specification by implementing each export as a getter (line A) that returns the current value of the exported variable. This looks as follows.
const exports = module.exports = Object.create(null, {
__esModule: {
value: true
},
[Symbol.toStringTag]: {
value: 'Module'
},
foo: {
enumerable: true,
get() { return foo; }
},
});
Object.freeze(exports);
let foo = 1;
foo = 2;
function bar() {
foo++;
}
Each property in the second argument of Object.create()
is defined via a property descriptor. For example, __esModule
is a non-enumerable data property whose value is true
and foo
is an enumerable getter.
If you use spec mode without transpiling let
(as I’m doing in this blog post), exports will also handle the temporal dead zone correctly (you can’t access an export before its declaration was executed).
The object stored in exports
is an approximation of an ECMAScript module record, which holds an ES module plus its metadata.
Step 2: The transpiled non-spec-mode Babel code always refers to imports via the imported module. It never stores their values in variables. That way, the live connection is never broken. For example:
// Input
import {foo} from 'bar';
console.log(foo);
// Output
var _bar = require('bar');
console.log(_bar.foo);
Spec mode handles this step the same way.
Without spec mode, the transpiled Babel code lets you change properties of imported namespace objects and add properties to them. Additionally, namespace objects still have Object.prototype
as their prototype when they shouldn’t have one.
// otherModule.js
export function foo() {}
// main.js
import * as otherModule from './otherModule.js';
otherModule.foo = 123; // should not be allowed
otherModule.bar = 'abc'; // should not be allowed
// proto should be null
const proto = Object.getPrototypeOf(otherModule);
console.log(proto === Object.prototype); // true
As previously shown, spec mode fixes this by freezing exports
and by creating this object via Object.create()
.
exports
Without spec mode, Babel lets you add things to exports
and work around ES module exporting:
export function foo() {} // OK
exports.bar = function () {}; // should not be allowed
As previously shown, spec mode prevents this by freezing exports
.
In non-spec mode, Babel allows you to do the following:
// someModule.js
export function foo() {}
// main.js
import {bar} from './someModule.js'; // should be error
console.log(bar); // undefined
In spec mode, Babel checks during importing that all imports have corresponding exports and throws an error if they don’t.
The way it looks now, ES modules in Node.js will only let you default-import CommonJS modules:
import {mkdirSync} from 'fs'; // no
mkdirSync('abc');
import fs from 'fs'; // yes
fs.mkdirSync('abc');
import * as fs from 'fs';
fs.mkdirSync('abc'); // no
fs.default.mkdirSync('abc'); // yes
That is unfortunate, because it often does not reflect what is really going on – whenever a CommonJS module simulates named exports via an object. As a result, turning such a module into an ES module means that import statements have to be changed.
However, it can’t be helped (at least initially), due to how much the semantics of both kinds of module differ. Two examples:
The previous subsection mentioned a check for declarative imports – they must all exist in the modules one imports from. This check must be performed before the body of the module is executed. You can’t do that with CommonJS modules, which is why you can only default-import them.
module.exports
may not be an object; it could be null
, undefined
, a primitive value, etc. A default import makes it easier to deal with these cases.
In non-spec mode, all imports shown in the previous code fragment work. Spec mode enforces the default import by wrapping imported CommonJS modules in module records, via the function specRequireInterop()
:
function specRequireInterop(obj) {
if (obj && obj.__esModule) {
// obj was transpiled from an ES module
return obj;
} else {
// obj is a normal CommonJS module,
// wrap it in a module record
var newObj = Object.create(null, {
default: {
value: obj,
enumerable: true
},
__esModule: {
value: true
},
[Symbol.toStringTag]: {
value: 'Module'
},
});
return Object.freeze(newObj);
}
}
ES modules treat all module specifiers as URLs (much like the src
attribute in script
elements). That leads to a variety of issues: ending module specifiers with .js
may become common, the %
character leads to URL-decoding, etc.
Importing modules statically (import
statements) or dynamically (import()
operator) resolves module specifiers roughly the same as require()
(source):
import './foo';
// looks at
// ./foo.js
// ./foo/package.json
// ./foo/index.js
// etc.
import '/bar';
// looks at
// /bar.js
// /bar/package.json
// /bar/index.js
// etc.
import 'baz';
// looks at:
// ./node_modules/baz.js
// ./node_modules/baz/package.json
// ./node_modules/baz/index.js
// and parent node_modules:
// ../node_modules/baz.js
// ../node_modules/baz/package.json
// ../node_modules/baz/index.js
// etc.
import 'abc/123';
// looks at:
// ./node_modules/abc/123.js
// ./node_modules/abc/123/package.json
// ./node_modules/abc/123/index.js
// and ancestor node_modules:
// ../node_modules/abc/123.js
// ../node_modules/abc/123/package.json
// ../node_modules/abc/123/index.js
// etc.
The following non-local dependencies will not be supported by ES modules on Node.js:
$NODE_PATH
$HOME/.node_modules
$HOME/.node_libraries
$PREFIX/lib/node
require.extensions
won’t be supported, either.
As far as URL protocols go, Node.js will support at least file:
. Browsers support all protocols, including data:
.
In browsers, the resolution of module specifiers will probably continue to work as they do when you use CommonJS modules via Browserify and webpack:
'baz'
) to URLs ('./node_modules/baz/index.js'
). It may additionally combine multiple ES modules into either a single ES module or a custom format.'baz'
to './node_modules/baz/index.js'
, etc.If we are already transpiling module specifiers, it’d be nice if we could also have variables in them. The main use case being going from:
import '../../../util/tool.js';
to:
import '$ROOT/util/tool.js';
With ES modules, there are now two kinds of files in JavaScript:
For more information consult Sect. “Browsers: asynchronous modules versus synchronous scripts” in “Exploring ES6”.
Both files are used differently and their grammars differ. However, as of now, there is overlap: some files can be either scripts or modules. For example:
console.log(this === undefined);
In order to execute this file correctly, you need to know whether it is a script (in which case it logs false
) or a module (in which case it logs true
).
In browsers, it is always clear whether a file is a script or a module. It depends on how one refers to it:
<script src="foo.js">
<script src="foo.js" type=module>
import 'foo.js';
In Node.js, the plan is to allow declarative imports of CommonJS files. Then one has to decide whether a given file is an ES module or not.
Two approaches for doing so were rejected by the Node.js community:
"use module";
package.json
to specify which modules are ESM, as outlined in “In Defense of .js”.Two other approaches are currently being discussed:
.mjs
(details).The former approach is currently being favored, but I prefer the latter approach, because detection would not require “looking into” files. The downside is that JavaScript tools (editors etc.) would need to be made aware of the new file name extension. But they also need to be updated to handle ES modules properly.
James M. Snell recently tweeted where Node.js is w.r.t. supporting ES modules:
Interoperability looks as follows:
import
: ESM and CommonJS (default export only)import()
: ESM and CommonJS (module record property default
)require()
: ESM (module record) and CommonJSimport()
: ESM and CommonJS (module record property default
)require()
: ESM (module record) and CommonJSimport()
– dynamically importing ES modules” (2ality blog post)Acknowledgements: Thanks to Bradley Farias and Diogo Franco for reviewing this blog post.