An Update on ES6 Modules in Node.js

James M Snell
Feb 11, 2017 · 5 min read

A few months ago I wrote an article describing the various differences that exist between Node.js CommonJS modules and the new ES6 Module system; and described a number of challenges inherent with implementing the new model in Node.js core. Here, I want to provide an update on how things are progressing.

Knowing when you know what you need to know

For instance, suppose I have the following simple CommonJS module (let’s call the module 'foobar') :

function foo() {
return 'bar';
}
function bar() {
return 'foo';
}
module.exports.foo = foo;
module.exports.bar = bar;

Now let’s make use of the module in a *.js file named app.js :

const {foo, bar} = require('foobar');
console.log(foo(), bar());

When I run $node app.js , the Node.js binary loads the app.js file, parses it, and begins evaluating the code. While evaluating, the require() function is called, which synchronously loads the contents of foobar.js in memory, synchronously parses and compiles the JavaScript code, and synchronously evaluates the code, returning the value of module.exports as the return value of require('foobar') in app.js . As soon as the require() function returns in app.js , the shape of the foobar module is known and can be used. All of this happens within the course of the same Node.js event loop tick.

Critical to understanding the difference between CommonJS and ES6 modules is the fact that the shape (the API) of a CommonJS module cannot be determined until after the code is evaluated — and even after evaluation, the shape can be mutated by other code at any time.

Here is the “equivalent” module written using ES6 syntax:

export function foo() {
return 'bar';
}
export function bar() {
return 'foo';
}

And the code using it:

import {foo, bar} from 'foobar';
console.log(foo());
console.log(bar());

What happens with the ES6 module, according to the ECMAScript standard, is a very different set of steps than what is implemented in the CommonJS case. The first step, loading the contents of the file from disk is largely the same but may happen asynchronously. When the contents of the file are available, they are parsed. While parsing, the shape of the module as defined by the export statements is determined prior to evaluating the code. Once the shape is determined, the code is then evaluated. It’s important to keep in mind that all import and export statements are resolved to their targets before any code is actually evaluated. It is also important to note that the ES6 specification allows this resolution step to occur asynchronously. In Node.js terms, this means loading the contents of the script, resolution of the module imports and exports, and evaluation of the module code would occur over multiple turns of the event loop.

Timing is Everything

Unfortunately, it’s just not going to be that easy.

Specifically, because ES6 modules are loaded, resolved and evaluated asynchronously, it will not be possible to require() an ES6 module. The reason is because require() is a fully synchronous function. It would be far too disruptive a change to the ecosystem for us to modify the semantics of require() to allow it to do asynchronous loading. We are therefore considering the possibility of implementing a require.import() function that is modeled after the proposed ES6 import()function (see here). This function would return a Promise that completes when the ES6 module is loaded. This is not optimal, but it would allow ES6 modules to be used from within existing CommonJS style Node.js code.

One bit of good news, however, is that it should be easily possible to use CommonJS modules from inside an ES6 module using the import statement. This is because asynchronous loading is not always required. There are a number of modifications to the ECMAScript language specification that will better support this, but when all is said and done it should just work.

There is one significant caveat tho…

Alas, the poor Named Import

import {foo, bar} from 'foobar';

The variables foo and bar are imported from foobar during the resolution phase — before any of the code is actually evaluated. This is possible in the ES6 Module world because the shape of the module is known in advance.

With CommonJS, on the other hand, the shape of a module is not known until after the code is evaluated. What this means is, without making significant changes to the ECMAScript language spec, it will not be possible to use Named Imports from a CommonJS module. Instead, developers will be required to use what ES6 Modules call the “default” export. For example, using the CommonJS module example at the opening of this post, the import using it would be:

import foobar from 'foobar';
console.log(foobar.foo(), foobar.bar());

The difference here is subtle but important. When using the import statement to import from a CommonJS module, it will simply not be possible to use the syntax:

import {foo, bar} from 'foobar';

And havefoo and bar resolve to the foo() and bar() functions exported by the CommonJS module.

But it works in Babel!

Michael Jackson Script

What this means practically is that Node.js will need to have some kind of mechanism for identifying in advance what kind of file is being loaded. While many options have been explored, the solution that we keep coming back to as being the least bad is introducing a new *.mjs file extension to explicitly identify JavaScript files to be handled as ES6 Modules. (We have affectionately called these “Michael Jackson Script” files in the past).

In other words, given two files foo.js and bar.mjs , using import * from 'foo' will treat foo.js as CommonJS while import * from 'bar' will treat bar.mjs as an ES6 Module.

Timeline

Node.js Collection

Community-curated content for the millions of Node.js

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store