Making transpiled ES modules more spec-compliant
In this blog post, you’ll learn:
- How a proposed “spec mode” for Babel makes transpiled ES modules more spec-compliant. That’s a crucial step in preparing for native ES modules.
- How ES modules and CommonJS modules will interoperate on Node.js.
- How far along ES module support is on browsers and Node.js.
Transpiling ES modules to CommonJS via Babel
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
}]
]
}
How does the spec mode work?
In this section, I explain where current transpilation deviates from ES module semantics and how the spec mode fixes that.
ES module imports are live views of exports
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.
In ES modules, imported namespace objects are immutable and have no prototype
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()
.
In ES modules, you can’t access and change 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 ES modules, you can only import what has been exported
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.
An ES module can only default-import CommonJS modules
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 benull
,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 module specifiers are URLs
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.
Import path resolution in Node.js
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:
.
Import path resolution in browsers
In browsers, the resolution of module specifiers will probably continue to work as they do when you use CommonJS modules via Browserify and webpack:
- You will install native ES modules via npm.
- A module bundler will transpile modules. At the very least it will convert Node.js-style specifiers (
'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. - As an alternative to transpiling modules statically, it is conceivable that you’ll be able to customize a module loader in a manner similar how RequireJS does it: mapping
'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';
Open issue: distinguishing ES modules from other JavaScript files
With ES modules, there are now two kinds of files in JavaScript:
- Modules: can declaratively import modules, live in a module-local scope, etc.
- Scripts: cannot declaratively modules, live in global scope, etc.
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
).
Browsers
In browsers, it is always clear whether a file is a script or a module. It depends on how one refers to it:
- Script:
<script src="foo.js">
- Module:
<script src="foo.js" type=module>
- Module:
import 'foo.js';
Node.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:
- Marking ES modules via
"use module";
- Using metadata in
package.json
to specify which modules are ESM, as outlined in “In Defense of .js”.
Two other approaches are currently being discussed:
- Ensure that ES modules and scripts have non-overlapping grammars (details).
- Give modules the dedicated file name extension
.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.
How long until we have native ES modules?
Native ES modules in browsers
- Firefox: in development
- Chrome: in development
- Edge: available behind a flag in EDGE 15 Preview Build 14342+
- Webkit: available in Safari Technology Preview 21+
Native ES modules in Node.js
James M. Snell recently tweeted where Node.js is w.r.t. supporting ES modules:
- Prerequisite: Several issues need to be sorted out before Node.js can support ES modules – mainly: async vs. sync loading, timing, and the ability to support CommonJS modules.
- Once these obstacles are removed, the JavaScript engines that Node.js is based on (esp. V8) need to implement the spec changes.
- Given the time needed for the spec changes and the implementation, support for ES modules in Node.js will, at the earliest, be ready in early 2018. Experimental previews may happen before then, but nothing that is officially supported.
Interoperability looks as follows:
- ES module (ESM):
- Import declaratively via
import
: ESM and CommonJS (default export only) - Import programmatically via
import()
: ESM and CommonJS (module record propertydefault
) - Import programmatically via
require()
: ESM (module record) and CommonJS
- Import declaratively via
- CommonJS module:
- Import programmatically via
import()
: ESM and CommonJS (module record propertydefault
) - Import programmatically via
require()
: ESM (module record) and CommonJS
- Import programmatically via
Further reading
Sources of this blog post
- “Wondering where we are with regard to ES6 module support for Node.js?” (tweets by @jasnell)
- “Add a spec mode to transform-es2015-modules-commonjs” (pull request by Diogo Franco)
- “ES6 Module Interoperability” (Node.js enhancement proposal by Bradley Farias)
More information on ES modules
- “Native ECMAScript modules – the first overview” (article by Serg Hospodarets)
- “Modules” (chapter in “Exploring ES6”)
- “ES proposal:
import()
– dynamically importing ES modules” (2ality blog post) - “The future of bundling JavaScript modules” (chapter in “Setting up ES6”)
Acknowledgements: Thanks to Bradley Farias and Diogo Franco for reviewing this blog post.
Comments
Post a Comment