JavaScript Modules
This blog post is a part of our ES6 series tutorials where we will make an overview of the new ES6 module syntax.
Intro in JS Modules
Modular programming is a key concept in programming. It allows us to divide our work into multiple files and make them access one another. This is a common design pattern in every object-oriented programming language. First built-in support for modules in JavaScript appears with the 6th edition of the ECMAScript standard (before that the community had to use libraries that allow writing modular code).
Essentially, modules in ES6 are files that contain JavaScript code, that automatically runs in strict mode (and there is no way to opt-out). That code may contain declarations as variable declarations, function declarations, class declarations, etc. These declarations by default remain local to the module unless you explicitly export them in order to make them accessible by other modules. ES6 modules syntax is very declarative: to export certain declarations you use the keyword export
, to consume the exported declarations in a different module you use import
.
Note: Before we move on, we have to mention that this feature is just beginning to be implemented in browsers natively. It is only supported by Chrome 61+ and Safari 10.1+. Nevertheless, if you want to make modules work on the web do the following:
- In Chrome, you can run them through a local web server because their MIME types have to be validated. This can be hard for new users and is slower than double clicking your file.
- Run them in Safari, you either have to download a version from 2012 or you have to be a Mac user.
Other browsers only implement modules experimentally, which is buggy and not perfect for serious development.
This is where GT2 comes in, it supports modules and also serves the purpose of a web server, so you execute every file as you would normally do.
Now, let’s take a look at a simple native module example:
1 2 3 4 5 6 7 8 9 10 |
<!--index.html--> <!DOCTYPE html> <html> <head> <!-- script tag with type=module includes the file --> <script type="module" src="main.js"></script> </head> <body> </body> </html> |
Here is the module file:
1 2 3 4 |
// main.js import { sayHello } from "./utils.js"; sayHello(); |
And the imported utils:
1 2 3 4 |
// utils.js export function sayHello() { console.log('Hello! I am imported.') } |
The following picture shows the result in the browser, ‘Hello! I am imported.’ is logged:
Let’s check out the scope of a module’s variable. As we said above, it is local to the module it was declared in unless you export it:
1 2 3 4 5 6 7 8 9 10 |
<!--index.html--> <!DOCTYPE html> <html> <head> <!-- script tag with type=module includes the file --> <script type="module" src="main.js"></script> </head> <body> </body> </html> |
The module:
1 2 3 4 |
// main.js import variable from "./utils.js" console.log(variable); |
The exported variable:
1 2 |
// utils.js export default 1; |
The value of the variable is logged:
But the following will produce an error about attempting to reassign a read-only property. Every import is a live connection to the exported data. Imports are read-only.
Unqualified imports (import x from 'foo'
) are like const
-declared variables:
1 2 |
//utils.js export default 1; |
1 2 3 4 |
//main.js import variable from "./utils.js" variable = 3; |
Let’s check that the native modules are in strict mode:
1 2 3 |
// module.js var x; delete x; // Uncaught SyntaxError: Delete of an unqualified identifier in strict mode. |
1 2 3 4 |
// non-module.js // the following code will run without an error var x; delete x; // False |
Another example that illustrates the strict mode
with inline scripts in HTML:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<!--index.html--> <!DOCTYPE html> <html> <head> </head> <body> <script type="module"> console.log(this); </script> <script> console.log(this); </script> </body> </html> |
Which will produce different results:
In addition to the compact syntax for importing and exporting, ES6 modules have substantial scoping benefits and support for cyclic dependencies between modules. We will look at each of these advantages below.
Modules’ syntax and semantics
There are two kinds of exports:
- a named export: useful when exporting multiple declarations. You can actually rename imports and exports which solves the name collision problem.
- a default export: one per module, ES6 favors the single/default export style and gives the sweetest syntax to importing the default.
Let’s look at the different ways of exporting and importing in ES6:
Exporting
As we have seen, you can use the export
keyword to export parts of the code to make it public to other modules.
- Named export
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// module my-module.js // export variables export var foo = 'bar'; export let counter = 0; export const SPECIAL_NUM = 5; // export function export function multiply(x, y) { return x * y; }; // export class export class Person { constructor(name, age) { this.name = name; this.age = age; } } // without exporting the function remains private to the module function divide(x, y) { return x / y; } // you can define a function function sum(x, y) { return x + y; } // and export it later export { sum } |
Note: Unless you are using default
keyword, every class or function declarations require a name.
Importing
To consume exported declarations in another module we use the import
keyword. The first part of import
statement is the bindings you аre importing, the second part is the path to the module from which you are importing. The path can be relative to the location of the importing module or absolute, pointing directly to the file of the module to be imported.
1 |
import { multiply, sum } from "./my-module.js"; |
Ways to import bindings:
- single binding
1 2 3 |
import { multiply } from "./my-module.js"; console.log(multiply(2, 3)); // 6 |
- multiple bindings
1 2 3 |
import { sum, divide, SPECIAL_NUM } from "./my-module.js"; console.log(sum(4, SPECIAL_NUM)); //9 console.log(divide(10, SPECIAL_NUM)); //2 |
- import everything
1 2 |
import * as example from "./my-module.js"; console.log(example.sum(2, example.SPECIAL_NUM)); //7 |
Renaming Exports and Imports
Notice that in the last example, all exported bindings are loaded into an object example. Sometimes you may want to use a different name for an imported variable. You can use the as
keyword to do that:
1 2 3 4 5 6 |
// module my-module.js function sum(num1, num2) { return num1 + num2; }; export { sum as add } |
1 2 |
// module another-module.js import { add } from "./my-module.js" |
You could name the function something else to avoid name collisions:
1 2 3 |
// module another-module.js import { add as sum } from "./my-module.js"; console.log(sum(2, 3)); //5 |
- Default export
The default value for a module is a single variable, function or class, you can only set one default export per module.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// module my-module.js // no name required export default function() { console.log('Hello!'); }; // you can specify identifier function sayHello() { console.log('Hello!'); } export default sayHello; // with the renaming syntax function sayHello() { console.log('Hello!'); } export { sayHello as default }; |
Importing default values:
1 2 3 4 |
// module another-module.js // import the default import sayHello from "my-module.js"; sayHello(); // Hello! |
Note: No curly braces are used.
There are modules that export both a default and non-default bindings:
1 2 3 4 5 6 |
// module my-module.js; export let foo = 'bar'; export default function(x, y) { return x + y; }; |
1 2 3 4 |
// module another-module.js import sum, { foo } from "./my-module.js"; console.log(sum(3, 5)); // 8 console.log(foo) // bar |
Note: The comma that separates the bindings, also the default comes before the non-defaults.
You can use the renaming syntax too:
1 2 3 |
import { default as sum, foo } from "my-module.js"; console.log(sum(3, 5)); // 8 console.log(foo); // bar |
Scoping benefits
One of the main goals ES6 has is to solve the scope problem that JavaScript owns (that everything shares one global scope). That is where the modules come in. Declarations created in the top level of a module are not automatically added to the shared global scope. They exist only within the top-level scope of the module. However, the value of this
in the module is undefined.
In order to load modules with different restrictions preset, modules have to be explicitly declared as such with the type="module"
syntax. More information about module loading, you can find here.
Cyclic dependencies
First of all, let’s make it clear what cyclical dependencies are. They appear when you have two files which import one another (e.g. A.js imports B.js and B.js imports A.js). It is not a recommended design pattern because it leads to that both files can be used and evolved only together. Nevertheless, ES6 supports cyclic dependencies, because there are some edge cases where you need this kind of behavior. For example, tree structures can use cyclic dependencies when child nodes refer to their parents (e.g. the DOM).
Let’s compare how CommonJS (library that allows modular programming) and ES6 handle cyclic dependencies. In CommonJS, if module B requires module A whose body is currently being evaluated, it gets back A‘s exports object in its current state (line 1). That enables B to refer to properties of that object inside its exports (line 2). The properties are filled in after B’s evaluation is finished, at which point B’s exports work properly.
1 2 3 |
//a.js var b = require('b'); exports.foo = function () { ... }; |
1 2 3 4 5 6 |
//b.js var a = require('a'); // (1) // Can’t use a.foo in module body, but it will be filled in later exports.bar = function () { a.foo(); // (2) }; |
1 2 |
//main.js var a = require('a'); |
But CommonJS has two limitations:
- in Node.js single-value exports don’t work.
If you do that in module A:
module.exports = function () { ... }
you would not be able to use the exported function in module B because B‘s variable a
would still refer to A‘s original exports object.
- you can’t use named exports directly.
That is, module B can’t import a.foo
like this:
foo = require('a').foo
foo
would simply be undefined
.
ES6 way to handle the listed limitations is that modules export bindings, not values, this guarantees that the connection to variables declared inside the module body stays alive:
1 2 3 4 5 |
// my-module.js export let counter = 0; export function increment() { counter++; } |
1 2 3 4 5 |
// another-module.js import { inc, counter } from 'lib.js'; console.log(counter); // 0 inc(); console.log(counter); // 1 |
It does not matter whether you access a named export directly or via its module, there is an indirection involved in either case and it always works.
Further reading
JavaScript modules for C# developers
ECMAScript 6 modules: the final syntax
We hope that this initial blog post gave you a detailed understanding of ES6 module syntax. If you have any questions write a comment below, tweet us or start a discussion in our Forum.