Like I did in previous articles on the series, I would love to point out that you should probably set up Babel and follow along the examples with either a REPL or the
babel-node
CLI and a file. That’ll make it so much easier for you to internalize the concepts discussed in the series. If you aren’t the “install things on my computer” kind of human, you might prefer to hop on CodePen and then click on the gear icon for JavaScript – they have a Babel preprocessor which makes trying out ES6 a breeze. Another alternative that’s also quite useful is to use Babel’s online REPL – it’ll show you compiled ES5 code to the right of your ES6 code for quick comparison.
Before getting into it, let me shamelessly ask for your support if you’re enjoying my ES6 in Depth series. Your contributions will go towards helping me keep up with the schedule, server bills, keeping me fed, and maintaining Pony Foo as a veritable source of JavaScript goodies.
Thanks for reading that, and let’s dive deep into the ES6 module system.
The ES6 Module System
Before ES6 we really went out of our ways to obtain modules in JavaScript. Systems like RequireJS, Angular’s dependency injection mechanism, and CommonJS have been catering to our modular needs for a long time now – alongside with helpful tools such as Browserify and Webpack. Still, the year is 2015 and a standard module system was long overdue. As we’ll see in a minute, you’ll quickly notice that ES6 modules have been heavily influenced by CommonJS. We’ll look at export
and import
statements, and see how ES6 modules are compatible with CommonJS, as we’ll go over throughout this article.
Today we are going to cover a few areas of the ES6 module system.
Strict Mode
In the ES6 module system, strict mode is turned on by default. In case you don’t know what strict mode is, it’s just a stricter version of the language that disallows lots of bad parts of the language. It enables compilers to perform better by disallowing non-sensical behavior in user code, too. The following is a summary extracted from changes documented in the strict mode article on MDN.
- Variables can’t be left undeclared
- Function parameters must have unique names (or are considered syntax errors)
with
is forbidden- Errors are thrown on assignment to read-only properties
- Octal numbers like
00840
are syntax errors - Attempts to
delete
undeletable propertiesthrow
an error delete prop
is a syntax error, instead of assumingdelete global[prop]
eval
doesn’t introduce new variables into its surrounding scopeeval
andarguments
can’t be bound or assigned toarguments
doesn’t magically track changes to method parametersarguments.callee
throws aTypeError
, no longer supportedarguments.caller
throws aTypeError
, no longer supported- Context passed as
this
in method invocations is not “boxed” (forced) into becoming anObject
- No longer able to use
fn.caller
andfn.arguments
to access the JavaScript stack - Reserved words (e.g
protected
,static
,interface
, etc) cannot be bound
In case it isn’t immediately obvious – you should 'use strict'
in all the places. Even though it’s becoming de-facto in ES6, it’s still a good practice to use 'use strict'
everywhere in ES6. I’ve been doing it for a long time and never looked back!
Let’s now get into export
, our first ES6 modules keyword of the day!
export
In CommonJS, you export values by exposing them on module.exports
. As seen in the snippet below, you could expose anything from a value type to an object, an array, or a function.
module.exports = 1
module.exports = NaN
module.exports = 'foo'
module.exports = { foo: 'bar' }
module.exports = ['foo', 'bar']
module.exports = function foo () {}
ES6 modules are files that export
an API – just like CommonJS modules. Declarations in ES6 modules are scoped to that module, just like with CommonJS. That means that any variables declared inside a module aren’t available to other modules unless they’re explicitly exported as part of the module’s API (and then imported in the module that wants to access them).
Exporting a Default Binding
You can mimic the CommonJS code we just saw by changing module.exports =
into export default
.
export default 1
export default NaN
export default 'foo'
export default { foo: 'bar' }
export default ['foo', 'bar']
export default function foo () {}
Contrary to CommonJS, export
statements can only be placed at the top level in ES6 modules – even if the method they’re in would be immediately invoked when loading the module. Presumably, this limitation exists to make it easier for compilers to interpret ES6 modules, but it’s also a good limitation to have as there aren’t that many good reasons to dynamically define and expose an API based on method calls.
function foo () {
export default 'bar' // SyntaxError
}
foo()
There isn’t just export default
, you can also use named exports.
Named Exports
In CommonJS you don’t even have to assign an object to module.exports
first. You could just tack properties onto it. It’s still a single binding being exported – whatever properties the module.exports
object ends up holding.
module.exports.foo = 'bar'
module.exports.baz = 'ponyfoo'
We can replicate the above in ES6 modules by using the named exports syntax. Instead of assigning to module.exports
like with CommonJS, in ES6 you can declare bindings you want to export
. Note that the code below cannot be refactored to extract the variable declarations into standalone statements and then just export foo
, as that’d be a syntax error. Here again, we see how ES6 modules favor static analysis by being rigid in how the declarative module system API works.
export var foo = 'bar'
export var baz = 'ponyfoo'
It’s important to keep in mind that we are exporting bindings.
Bindings, Not Values
An important point to make is that ES6 modules export bindings, not values or references. That means that a foo
variable you export would be bound into the foo
variable on the module, and its value would be subject to changes made to foo
. I’d advise against changing the public interface of a module after it has initially loaded, though.
If you had an ./a
module like the one found below, the foo
export would be bound to 'bar'
for 500ms and then change into 'baz'
.
export var foo = 'bar'
setTimeout(() => foo = 'baz', 500)
Besides a “default” binding and individual bindings, you could also export lists of bindings.
Exporting Lists
As seen in the snippet below, ES6 modules let you export
lists of named top-level members.
var foo = 'ponyfoo'
var bar = 'baz'
export { foo, bar }
If you’d like to export something with a different name, you can use the export { foo as bar }
syntax, as shown below.
export { foo as ponyfoo }
You could also specify as default
when using the named member list export
declaration flavor. The code below is the same as doing export default foo
and export bar
afterwards – but in a single statement.
export { foo as default, bar }
There’s many benefits to using only export default
, and only at the bottom of your module files.
Best Practices and export
Having the ability to define named exports, exporting a list with aliases and whatnot, and also exposing a a “default” export
will mostly introduce confusion, and for the most part I’d encourage you to use export default
– and to do that at the end of your module files. You could just call your API object api
or name it after the module itself.
var api = {
foo: 'bar',
baz: 'ponyfoo'
}
export default api
One, the exported interface of a module becomes immediately obvious. Instead of having to crawl around the module and put the pieces together to figure out the API, you just scroll to the end. Having a clearly defined place where your API is exported also makes it easier to reason about the methods and properties your modules export.
Two, you don’t introduce confusion as to whether export default
or a named export – or a list of named exports (or a list of named exports with aliases…) – should be used in any given module. There’s a guideline now – just use export default
everywhere and be done with it.
Three, consistency. In the CommonJS world it is usual for us to export a single method from a module, and that’s it. Doing so with named exports is impossible as you’d effectively be exposing an object with the method in it, unless you were using the as default
decorator in the export
list flavor. The export default
approach is more versatile because it allows you to export
just one thing.
Four, – and this is really a reduction of points made earlier – the export default
statement at the bottom of a module makes it immediately obvious what the exported API is, what its methods are, and generally easy for the module’s consumer to import
its API. When paired with the convention of always using export default
and always doing it at the end of your modules, you’ll note using the ES6 module system to be painless.
Now that we’ve covered the export
API and its caveats, let’s jump over to import
statements.
import
These statements are the counterpart of export
, and they can be used to load a module from another one – first and foremost. The way modules are loaded is implementation-specific, and at the moment no browsers implement module loading. This way you can write spec-compliant ES6 code today while smart people figure out how to deal with module loading in browsers. Transpilers like Babel are able to concatenate modules with the aid of a module system like CommonJS. That means import
statements in Babel follow mostly the same semantics as require
statements in CommonJS.
Let’s take lodash
as an example for a minute. The following statement simply loads the Lodash module from our module. It doesn’t create any variables, though. It will execute any code in the top level of the lodash
module, though.
import 'lodash'
Before going into importing bindings, let’s also make a note of the fact that import
statements, – much like with export
– are only allowed in the top level of your module definitions. This can help transpilers implement their module loading capabilities, as well as help other static analysis tools parse your codebase.
Importing Default Exports
In CommonJS you’d import
something using a require
statement, like so.
var _ = require('lodash')
To import the default exported binding from an ES6 module, you just have to pick a name for it. The syntax is a bit different than declaring a variable because you’re importing a binding, and also to make it easier on static analysis tools.
import _ from 'lodash'
You could also import named exports and alias them.
Importing Named Exports
The syntax here is very similar to the one we just used for default exports, you just add some braces and pick any named exports you want. Note that this syntax is similar to the destructuring assignment syntax, but also bit different.
import {map, reduce} from 'lodash'
Another way in which it differs from destructuring is that you could use aliases to rename imported bindings. You can mix and match aliased and non-aliased named exports as you see fit.
import {cloneDeep as clone, map} from 'lodash'
You can also mix and match named exports and the default export. If you want it inside the brackets you’ll have to use the default
name, which you can alias; or you could also just mix the default import side-by-side with the named imports list.
import {default, map} from 'lodash'
import {default as _, map} from 'lodash'
import _, {map} from 'lodash'
Lastly, there’s the import *
flavor.
import
All The Things
You could also import the namespace object for a module. Instead of importing the named exports or the default value, it imports all the things. Note that the import *
syntax must be followed by an alias where all the bindings will be placed. If there was a default export, it’ll be placed in alias.default
.
import * as _ from 'lodash'
That’s about it!
Conclusions
Note that you can use ES6 modules today through the Babel compiler while leveraging CommonJS modules. What’s great about that is that you can actually interoperate between CommonJS and ES6 modules. That means that even if you import
a module that’s written in CommonJS it’ll actually work.
The ES6 module system looks great, and it’s one of the most important things that had been missing from JavaScript. I’m hoping they come up with a finalized module loading API and browser implementations soon. The many ways you can export
or import
bindings from a module don’t introduce as much versatility as they do added complexity for little gain, but time will tell whether all the extra API surface is as convenient as it is large.
Comments