ES6 modules, Node.js and the Michael Jackson Solution

JavaScript’s never had a standard way to import and export functionality from a source file to another. Well, it has: global variables. For example:

<script src="https://code.jquery.com/jquery-1.12.0.min.js"></script><script>
// `$` variable available here
</script>

This is far from ideal for a few reasons:

  • You may have conflicts with other libraries using the same variable names. That’s why many libraries have a noConflict() method.
  • You cannot correctly make cyclic references. If an A module depends on a B module and vice versa, in which order do we put the <script> tags?
  • Even if there are not cyclic references the order in which you put the <script> tags is important and hard to maintain.

CommonJS to the rescue

When Node.js and other server-side JavaScript solutions started to appear they agreed on a way to fix this problem. They created a broader specification called CommonJS. Regarding the importing/exporting problem, this specification defines a require() function that is injected by the runtime and an exports variable to export functionality.

Note: CommonJS is not the only specification. There are others such as UMD that, in fact, can be used in both: frontend and backend.

As the time went by there was an explosion of tools, specially to create single-page-applications. With larger code bases in the frontend and the need to share code between frontend and backend, many tools such as browserify and webpack started to implement and understand the CJS specification as a way to bypass the platform limitations: the lack of a good module system in the underlying platform (JavaScript and the browsers).

This is clearly a hack because the browser does not implement require() or exports What these tools do is to implement this functionality packing all the code together. Read more on how JavaScript bundlers work.

How ES6 modules work and why Node.js hasn’t implemented it yet

JavaScript is evolving a lot, specially with ES6, and this problem had to be solved. That’s why ES modules were born. They look a lot like CJS syntactically.

Let’s compare them. This is how we import something in both systems:

const { helloWorld } = require('./b.js') // CommonJS
import { helloWorld } from './b.js' // ES modules

This is how we export functionality:

// CommonJS
exports.helloWorld = () => {
console.log('hello world')
}
// ES modules
export function helloWorld () {
console.log('hello world')
}

Very similar, right?

It’s been a long time since Node.js has implemented 99% of ECMAScript 2015 (aka ES6), but we will need to wait until the end of 2017 for support for ES6 modules. And it will be only available behind a runtime flag! Why is it taking so long to implement ES6 modules in Node.js if they are so similar to CJS?

Well, the devil is in the details. The syntax is pretty similar between both systems, but the semantics are pretty different. There are also subtle edge cases that require a special effort to be 100% compatible with the specification.

Even though ES modules are not implemented in Node.js, they are implemented already in some browsers. For example we can test them in Safari 10.1. Let’s see some examples and we will see why the semantics are so important. I’ve created these three files:

// index.html
<script type="module" src="./a.js"></script>
// a.js
console.log('executing a.js')
import { helloWorld } from './b.js'
helloWorld()
// b.js
console.log('executing b.js')
export function helloWorld () {
console.log('hello world')
}

What do we see in the console when this is run? This is the result:

executing b.js
executing a.js
hello world

However, the same code using CJS and running it in Node.js:

// a.js
console.log('executing a.js')
import { helloWorld } from './b.js'
helloWorld()
// b.js
console.log('executing b.js')
export function helloWorld () {
console.log('hello world')
}

Will give us:

executing a.js
executing b.js
hello world

So… it has executed the code in different order! This is because ES6 modules are first parsed (without being executed), then the runtime looks for imports, loads them and finally it executes the code. This is called async loading.

On the other hand, Node.js loads the dependencies (requires) on demand while executing the code. Which is very different. In many case this may not make any difference, but in other cases it is a completely different behavior.

Node.js and web browsers need to implement this new way of loading code keeping the previous one. How do they know when to use a system and when the other one? Browsers know this because you specify it at the <script> level, as we’ve seen in the example with the type property:

<script type="module" src="./a.js"></script>

However, how does Node.js know? There’s been a lot of discussion about this and there’s been a lot of proposals (checking first the syntax and then deciding whether or not it should be treated as a module, defining it in the package.json file,…). Finally the approved proposal has been: the Michael Jackson Solution. Basically if you want a file to be loaded as an ES6 module you will use a different extension: .mjs instead of .js.

The extension name (.mjs) is the reason why this is sometimes dubbed the Michael Jackson Solution.

At the beginning it seemed to me a very bad decision, but now I think it’s the best solution, because it’s easy and any tool (text editor, IDE, preprocessor) will know the easiest possible way if a file needs to be treated as an ES6 module or not. And it only adds the minimal overhead possible to the loading process.

If you want to know more about the implementation status of ES6 modules in Node.js you should read this update.

A note about Babel

Babel implements ES6 modules, but… incorrectly. It doesn’t implement the full spec. So beware that if you are using Babel when switching to a native ES6 modules implementation, you may have side-effects.

Why ES6 modules are good and how to get the best of both worlds

ES6 modules are great for two main reasons:

  • They are a cross-platform standard. They will work in both Node.js and web browsers.
  • Imports and exports are static. It has to be that way because of how the loading process works. Remember that we said the runtime first loads the file, parses it and then, before executing it, it loads the dependencies? This is only possible if imports and exports are static. You cannot do import 'engine-' + browserVersion This is good for a reason: tools can do static analysis of the code, figure out which code is actually being used and tree shake it. This is specially useful when using third-party libraries: you never use all the functionality they provide, so you can remove lots of bytes of code that the user won’t ever execute.

But, does this mean that I can no longer import functionality dynamically? To me this is very useful. Many times I do things like:

const provider = process.env.EMAIL_PROVIDER
const emailClient = require(`./email-providers/${provider}`)

This way I get a different implementation with the same interface just with a configuration change, without having to load the code of all the implementations.

So, what happens with ES6 modules? Well, don’t worry, there’s a stage-3 proposal (which means it will likely be approved soon) that adds an import() function. This function accepts a path and returns the exported functionality as a promise.

So with ES6 modules and import() we will get the best of both worlds. 🚀

ES6 modules are great but it will take some time to adopt them. Hope all this information helps you be prepared for when that time comes!

Show your support

Clapping shows how much you appreciated Alberto Gimeno’s story.