ES6 is packed with features like iterators, generators, maps, sets, symbols, template strings and more. One of the biggest game changers to app architecture is the new modules, and different than the syntactic sugar of class
and extends
they’re an entirely new construct[1]. In this post I’ll cover ES6’s new module syntax and how it provides better performance, encapsulation, and error handling.
But first a little backstory. Engineers have long defined modules with workarounds on the global window
object using object literals, the module pattern, and any combination of IIFEs one can imagine. Each of these comes with a set of challenges from lack of encapsulation to forced singletons. In 2010 RequireJS became a popular tool to define modules client-side, being used on sites like PayPal, Dropbox and The New York Times. CommonJS, it’s counterpart, was adopted server-side by the Node.js community.
So why even use modules?
Why Modules? #
As JavaScript code scales developers often organize it into multiple source files. Makes sense, right? But if we take these files and simply load them in our HTML there are issues.
-
Performance - Loading multiple source files at runtime as
<script>
tags can block execution.
<body>
...
<script src="lib/angular.js"></script>
<script src="lib/angular-route.js"></script>
<script src="lib/lo-dash.js"></script>
<script src="src/A.js"></script>
<script src="src/B.js"></script>
<script src="src/C.js"></script>
<script src="src/D.js"></script>
</body>
Even though modern browsers load all scripts in parallel, separate network requests are made. And these scripts must execute in sequence, where one loading script will block a subsequent script from executing.
-
Unclear Dependencies - In
<script>
s above what ifD.js
depends onB.js
andC.js
whileB.js
andC.js
depend onA.js
. We could removeD.js
on it’s own but if we removedC.js
we’d need to removeD.js
too. But how would you know? A list can’t identify these relationships because they’re better modeled as directed acyclic graphs.
Image may be NSFW.
Clik here to view.
-
Heavy reliance on
window
object - All function declarations and variables in top level scope become attached to a single globalwindow
object. We want concise, snappy modules but instead get something akin to this.
Image may be NSFW.
Clik here to view.
Thankfully, a better approach has arrived.
ES6 Modules #
Modules support a one-module-per-file architecture using export
and import
declarations to communicate dependencies. They’re fast, encapsulated, catch errors earlier, and avoid the global window
object altogether.
A module can export any type of primitive or native object value, whether a string, number, boolean, object, array, function or more. A single module can import/export one primary value, called the default, along with multiple values called named imports/exports. We’ll focus on default first.
Default Imports/Exports #
ES6 prefers default imports/exports over named by giving them the most concise syntax.
Export
Examples |
Export Name | Module Requested | Import Name | Local Variable |
---|---|---|---|---|
export default function func() {...} |
“default” | “func” | ||
export default function() {...} |
“default” | “default” | ||
export default 42 |
“default” | “default” |
[3]
The default
keyword creates the default export. Easy, right? Remember though that each file can have only one default export.
Import
Example |
Module Requested | Import Name | Local Variable Name |
---|---|---|---|
import myDefault from "mod"; |
“mod” | “default” | “myDefault” |
The default exported value from mod.js
is imported as local variable myDefault
.
Named Imports/Exports #
Named import/export syntax is slightly more verbose. We either export variables inline, use {}
, or *
.
Export
Examples |
Export Name | Module Requested | Import Name | Local Variable |
---|---|---|---|---|
export var num = 100000 |
“num” | “num” | ||
export { str } |
“str” | “str” | ||
export { str as myStr } |
“myStr” | “str” | ||
export * from "someMod" |
“someMod” | “*” |
1st: Exports declaration var num = 100000
as a named export.
2nd: Curly braces export a previously declared variable. str
was declared earlier as a var
, function
, class
, or let
.
3rd: Same as 2nd except str
variable is renamed as export myStr
.
4th: Re-exports all named exports from someMod.js
.
Import
Curly braces { }
or an *
will import a named export.
Example |
Module Requested | Import Name | Local Variable |
---|---|---|---|
import { num } from "mod"; |
“mod” | “num” | “num” |
import { num as myNum } from "mod"; |
“mod” | “num” | “myNum” |
import * as myObj from "mod"; |
“mod” | “*” | “myObj” |
1st: The num
export from mod.js
is imported to local variable num
.
2nd: Same as 1st except the local variable myNum
references named export num
.
3rd: The *
imports all named exports from module mod.js
into a single object literal called myObj
More examples
myMod.js
var str = 'booyah';
var num = 100000;
// named exports
export str;
export num;
export function func() {
return 'do stuff';
}
// OR -- shorthand for named exports
// export { str, num, func }
// default export
export default function() {
return 'default';
}
main.js
// import 2 named exports
import { str, num } from "myMod"
// import default
import myDefault from "myMod"
// OR -- shorthand for default and named exports
// import myDefault, { str, num } from "myMod"
Note named export func
wasn’t imported into main.js
.
Beyond the syntax, ES6 modules
were designed with higher level goals.
1. Declarative Structure #
Module definitions use declarative syntax which is a bit foreign at first but becomes quite readable.
/**
* import named exports myNum, myObj from mod.js
*/
import { myNum, myObj } from 'mod';
Notice there’s no assignment. Most importantly, this simple syntax sets the stage for compile-time resolution.
2. Compile-Time Resolution #
When a page loads:
- JS text is parsed, and the “import”, and “export” syntax is found.
- Any dependencies are fetched and parsed.
- Once the dependency tree has been fetched the ES module loader maps all dependency exports to the module’s imports. [4]
All this occurs at compile-time, before any runtime code executes. Benefits include:
- faster lookups. Instead of assigning modules dynamic objects created at runtime like with RequireJS and CommonJS, static references resolve quicker in benchmarks because they don’t require PIC Guards[2].
- Developer specified imports and exports provide better encapsulation by using built-in language constructs over RequireJS and CommonJS’s use of mutable runtime objects.
- The interpreter can handle errors at compile-time before the script starts executing. e.g. trying to import a not-yet-created export.
3. Collections of Code #
ES6 modules
are defined broader than objects with properties. They’re instead “collections of code” where both default and named exports can be exported. But by allowing pieces of modules to be imported critics think it defies the essence of a module as a single unit of functionality. Do they merely pave the way for bags-of-methods modules?
Isaac Schlueter, who created NPM, echoed this concern.
If the author wants [the module] to export multiple things, then let them take on the burden of creating an object and deciding what goes on it. The syntax should suggest that a “module” ought to be a single “thing”, so that these util-bags appear as warty as they are. [2]
Dave Herman, the lead architect of ES6 modules replied.
If you want to export multiple functions, for example, you can export a single object with multiple function properties. But an object is a dynamic value. There’s no way to say create a compile-time aggregation of declarative things analogous to an object. Such as a collection of macros, or a collection of type definitions, or a collection of sub-modules that themselves contain static things. It’s one thing to encourage single-export modules, it’s another to require it. [2]
At least ES6 favors default imports/exports, giving them the best syntax compared to named exports.
4. Cyclic Dependancies & Async Loading #
Modules support cyclic dependencies, allowing module A to import module B while module B imports module A. Cyclic dependancies are usually a hallmark of poor design. But they may be useful in edge cases. For example, tree structures can use cyclic dependancies when child nodes refer to their parents (e.g. the DOM).
Browsers will likely be adding a Loader
with .get()
functionality for asynch loading but it’s only in a draft phase[5].
Modules are one of the best features of ES6. Despite the clunky syntax they provide better performance, encapsulation and error handling, while setting the stage for greater things like macros and types to come. I’d enjoy any questions or comments. Please feel free to reach out on
LinkedIn.
References #
[1] ES6 Interview with David Herman
[2] Static Module Resolution by David Herman
[3] ECMAScript 6 spec, 15.2 Modules
[4] ES Modules Suggestions For Improvement
[5] Loader - A Collection of Interesting Ideas