OdeToCode IC Logo

Modules in JavaScript Circa 2015

Wednesday, October 7, 2015

Until 2015, the JavaScript language has officially only offered only two types of variable scope – global scope and function scope. Avoiding global scope has been a primary architectural goal of nearly every library and framework authored over the last ten years. Avoiding global scope in the browser has meant we’ve relied heavily on closures and syntactical oddities like the immediately invoked function expression (IIFE) to provide encapsulation.

(function() {

    // code goes here, inside an IIFE

}());

Avoiding global scope also meant nearly every library and framework for the browser would expose functionality through a single global variable. Examples include $ or jQuery for the jQuery library, or _ for underscore and lodash.

After NodeJS arrived on the scene in 2009, Node developers found themselves creating larger code bases and consuming a larger number of libraries. This community adopted and put forward what now we call the CommonJS module standard. Shortly afterward, another community standard, the Asynchronous Module Definition standard (AMD), appeared for browser programming.

ECMAScript 2015 brings an official module standard to the JavaScript language. This module standard uses a different syntax than both CommonJS and AMD, but tools like WebPack and polyfills like the ES6 module loader make all of the module standards mostly interoperable. At this point in time, some preprocessing or polyfills are required to make the new module syntax work. Even though the syntax of the language is complete and standardized, the browser APIs and behaviors for processing modules are still a work in progress.

First, Why Modules?

The purpose of a module system is to allow JavaScript code bases to scale up in size. Modules give us a tool to manage the complexity of a large code base by providing just a few important features.

First, modules in JavaScript are file based. When using ES2015, instead of thinking about a script file, you should think of a script module. A file is a module. By default, any code you write inside the file is local to the module. Variables are no longer in the global scope by default, and there is no need to write a function or an IIFE to control the scope of a variable. Modules give us an implicit scope to hide implementation details.

Of course, some of the code you write in a module is code you might want to expose as an API and consume from a different module. ES2015 provides the export keyword for this purpose. Any object, function, class, value, or variable that you want to make available to the outside world is something you must explicitly export with the export keyword.

In a second module, you would use the import keyword to consume any exports of the first module.

The ability to spread code across multiple files and directories while still being able to access the functionality exposed by any other file without going through a global mediator makes modules an important addition to the JavaScript language. Perhaps no other feature of 2015 will have as much of an impact on the architecture of applications, frameworks, and libraries as modules. Let’s look at the syntax.

Module Syntax

Imagine you want to use an object that represents a person, and in a file named humans.js you place the following code.

function work(name) {
    return `${name} is working`;
}

export let person = {
    name: "Scott",
    doWork() {
        return work(this.name);
    }
    
}

Since we are working with modules, the work function remains hidden in the module scope, and no code outside of the module will have access to the work function. The person variable is an export of the module. More specifically, we call person a named export. Code inside of other modules can import person and work with the referenced object.

import {person} from "./lib/humans"

describe("The humans module", function () {

    it("should have a person", function () {
        expect(person.doWork()).toBe("Scott is working");
    });

});

There are a few points to make about the previous code snippet.

First, notice the module name does not require a .js extension. The filename is humans.js, but the module name for the file is humans.

Secondly, the humans module is in a subfolder of the test code in this example, so the module specifier is a relative path to the module (./lib/humans).

Finally, curly braces enclose the imports list from the humans module. The imports are a list because you can import more than one named export from another module. For example, if the humans module also exported the work function, the test code could have access to both exports with the following code.

import {person, work} from "./lib/humans"

You also have the ability to alias an import to a different name.

import {person as scott, work} from "./lib/humans"

describe("The humans module", function () {

    it("should have a person", function () {
        // now using scott instead of person
        expect(scott.doWork()).toBe("Scott is working");
    });

    it("should have a work function", function () {
        expect(work).toBeDefined();
    });
    
});

In addition to exporting variables, objects, values, and functions, you can also export a class. Imagine the humans module with the following code.

function work(name) {
    return `${name} is working`;
}

export class Person {

    constructor(name) {
        this.name = name;
    }

    doWork() {
        return work(this.name);
    }

}

Now the test code would look like the following.

import {Person} from "./lib/humans"

describe("The humans module", function () {

    it("should have a person class", function () {
        var person = new Person("Scott");
        expect(person.doWork()).toBe("Scott is working");

    });

});

Modules can also export a list of symbols using curly braces, instead of using the export keyword on individual declarations. As an example, we could rewrite the humans module and place all the exports in one location at the bottom of the file.

function work(name) {
    return `${name} is working`;
}

class Person {

    constructor(name) {
        this.name = name;
    }

    doWork() {
        return work(this.name);
    }
}

export {Person, work as worker }

Notice how an export list can also alias the name of an export, so the work function exports with the name worker.

Default Exports

The 2015 module standard allows each module to have a single default export. A module can have a default export and still export other names, but having a default export does impact how another module will import the default. First, here is how the humans module would look with a default export of the Person class.

function work(name) {
    return `${name} is working`;
}

export default class Person {

    constructor(name) {
        this.name = name;
    }

    doWork() {
        return work(this.name);
    }
    
}

As the code demonstrates, the default export for a module uses the default keyword.

A module who needs to import the default export of another module doesn’t specify a binding list with curly braces for the default export. Instead, the module simply defines a name for the incoming default, and the name doesn’t need to match the name used inside the exporting module.

import Human from "./lib/humans"

describe("The humans module", function () {

    it("should have a default export as a class", function () {
        var person = new Human("Scott");
        expect(person.doWork()).toBe("Scott is working");
    });

});

Mass Exportation and Importation

An import statement can use an asterisk to capture all the named exports of a module into a namespace object. Let’s change the humans module once again to export the Person class both as a named export and as a default export, and also export the work function.

function work(name) {
    return `${name} is working`;
}

class Person {
    constructor(name) {
        this.name = name;
    }
    doWork() {
        return work(this.name);
    }
}

export {work, Person}
export default Person

The test code can have access to all the exports of the humans module using import *.

import * as humans from "./lib/humans"

describe("The humans module", function () {

    it("should have a person class", function () {
        var person = new humans.Person("Scott");
        expect(person.doWork()).toBe("Scott is working");
    });

    it("should have a default export", function () {
        expect(humans.default).toBeDefined();
    });

}); 

Notice how the test code now has two paths to reach the Person class. One path is via humans.Person, the other path is humans.default.

An export statement can also use an asterisk. The asterisk is useful in scenarios where you want one module to gather exports from many sub-modules and publish them all as a unit. In CommonJS this scenario typically uses a file named index.js, and many of the existing module loader polyfills support using an index.js file when importing a directory.

For example, let’s add a file named creatures.js to the lib folder.

export class Animal {

    constructor(name) {
        this.name = name;
    }    

}

An index.js file in the lib folder can now combine the humans and creatures module into a single module.

export * from "./creatures"
export * from "./humans"

The test code can now import the lib directory and access features of both underlying modules.

import {Person, Animal} from "./lib/"

describe("The combined module", function () {

    it("should have a person class", function () {
        var person = new Person("Scott");
        expect(person.doWork()).toBe("Scott is working");
    });

    it("should have an Animal class", function () {
        expect(new Animal("Beaker").name).toBe("Beaker");
    });

});

Importing For the Side-Effects

In some scenarios you only want to reference a module so the code inside can execute and produce side-effects in the environment. In this case the import statement doesn't need to name any imports.

import "./lib"

Summary

We're coming to the close on this long series of posts covering ES2015. In the last few posts we'll look at new APIs the standard brings to life.