ES6 modules are set to become the future replacement for the AMD and CommonJS module formats. They are defined by the current ES6 Specification and will be implemented in JavaScript engines in the future in both the browser and the server. This article covers a variety of practical workflows for implementing ES6 modules. These approaches are very new, and there will still be edge cases, but these principles provide a path forward towards ES6 for use in both new and existing projects today.

The following workflows are based on the talk I gave recently at Fluent 2014, you can watch the 35 minute presentation covering roughly the same material here.

Why Use ES6 Modules

The ES6 Specification defines two primary module features:

  1. Module syntax The way we write modules in our code with the new import, export and module keywords.

  2. The module loader The module loading pipeline instructing the JavaScript engine how to handle loading modules. It comprehensively specifies the entire loading algorithm through a module loader class.

    This module loader instance can then be provided as a global variable in the environment, called System in the browser, which can then be called directly allowing dynamic loads in the browser with System.import('module-name').

The theory behind why we might want to start write our code using ES6 module syntax today is because it means we are writing our code based on what is a module specification for the future versions of the language.

Many web applications need to dynamically load scripts after the initial page load. In order to do this, we need a dynamic module loader. We can in fact use the System dynamic loader in browsers today in production using the ES6 Module Loader polyfill with the SystemJS loader extension.

The reason we would use the System browser loader with a polyfill is exactly the same reason we'd use ES6 module syntax - to be able to build our applications on top of a spec-compliant module system.

Future ES6 Loader Bundling Scenarios

There has been some complaint about the fact that the ES6 Module Loader provides no native bundling format. But this becomes clear when understood in terms of the time scales of adoption for the spec.

The way bundling will be enabled is through improvements at the protocol level such as SPDY and HTTP/2 which allow lots of small modules to be sent with similar performance to sending an entire file bundle.

There are also proposals at the specification level for other bundling options at the protocol level, so this is very much the focus of the problem.

The workflows shown here give us some bundling workflows that can work in browsers today, but the real solutions for bundling in the future will be for these approaches not to be necessary at all.

Tooling

These approaches are based on using Google's Traceur project for compilation, the benefit over the similar ES6 Module Transpiler build methods being that it also allows the use of other ES6 syntax features such as classes, arrow functions, generators, desctructuring etc.

The workflows below all use Traceur directly. The following tools provide compilation from ES6 to AMD or CommonJS with Traceur for existing build systems:

These tools effectively provide the --dir compilation as used in the workflows here.

Using ES6 modules with NodeJS or Browserify

Using ES6 modules with existing AMD projects

Using the SystemJS dynamic browser loader

Production bundling approaches for the SystemJS browser loader

Static Workflow Examples

For these examples, we use a very simple two module application, consisting of app/app.js and app/module.js.

app/module.js exports a single value:

  export var test = 'es6!';

The main entry point, app/app.js then loads from app/module.js with:

  import {test} from './module';
  console.log(test);

We use relative module syntax and ommit the .js extension just like in CommonJS and AMD.

Using other ES6 Features

Traceur supports a lot of other ES6 syntax features, including classes, generators, arrow functions and destructuring.

If you want to take advantage of these other ES6 features, all of the above build workflows still apply, except we need to then separately include the traceur-runtime.js file as well.

The runtime is only 6KB minified and gzipped in production, so it is not a production high cost for the benefits ES6 functionality.

For example, say we use classes in app/module.js now:

export var test = 'es6';
export class MyClass {
  constructor() {
    console.log('ES6 Class!');
  }
}

We then load this in app/app.js with:

import {test, MyCLass} from './module';
console.log(test);
new MyClass();

If we don't use other syntax, including the Traceur runtime isn't necessary. These differences in workflow are described below between these two cases.

Static Workflow 1: Running the app in NodeJS

To run the above in NodeJS, we need to first install Traceur, Google's ES6 to ES5 JavaScript transpiler. This converts the new ES6 module syntax into something existing JavaScript engines can understand.

To install Traceur, we do:

  npm install -g traceur

Now that we have Traceur installed, we can run our application directly from the directory root of our project with:

  traceur app/app

You should then see the incredibly rewarding console output, es6!.

Static Workflow 2: Compiling into CommonJS

If we want to publish our project to npm or use Browserify, what we can do is transpile our entire application into CommonJS first, and then provide that to users.

This can be done with the --dir option in Traceur:

  traceur --dir app app-build --modules=commonjs

The above tells Traceur to run through each ES6 module in the app directory and individually compile it into a corresponding CommonJS module in the app-build directory.

We can now run our entire application with NodeJS directly:

  node app-build/app

And again we should see the midly tantilising output, es6.

With Additional ES6 Features

If we had used ES6 classes, or another feature, it isn't enough to simple run the app.

Instead we first install Traceur as a local dependency for our project:

  npm install traceur --save

Then we create a new entry point, index.js, and load the runtime first:

require('traceur/bin/traceur-runtime');
require('./app-build/app');

Static Workflow 3: Browser Single File Build

So that's the server, now we want to make this remarkable application work in the browser.

Traceur makes it very easy to build for the browser with the out option:

  traceur --out app-build.js app/app

So it will read app/app.js, trace all the module dependencies, and then build them into a single file app-build.js.

Then we just load this into the browser:

<!doctype html>
  <script src="app-build.js"></script>

And we're done, the text es6 appearing in the browser console.

With Additional ES6 Features

traceur-runtime.js works equivalently in the browser. We copy the file from node_modules/traceur/bin/traceur-runtime.js or from GitHub (ensuring to use the correct version tag) and then include it with a <script> tag before loading anything else.

<!doctype html>
  <script src="traceur-runtime.js"></script>
  <script src="app-build.js"></script>

We now get our ES6 classes and generators etc. working in the browser.

Static Workflow 4: AMD Build

The previous workflow assumes that all of our code is in ES6, and that we want to build everything into a single file.

If using a build tool like the r.js optimizer, one can get more bundling flexibility though.

In this scenario, we build all our ES6 into AMD, just like we did for CommonJS. Then we can load it with an AMD loader and build with the r.js optimizer.

  traceur --dir app app-build --modules=amd

The above builds each ES6 module in the app directory individually into a corresponding AMD module in the app-build directory.

We can now load them with RequireJS (or any AMD loader):

<!doctype html>
  <script src="require.js"></script>
  <script>
    require(['app-build/app']);
  </sript>

The great thing about this workflow is we can now use ES6 code alongside existing AMD code and get flexibile bundling.

With Additional ES6 Features

Just like workflow 3, this can take advantage of ES6 features by including the Traceur runtime script before requiring the modules.

Dynamic Module Loader

In the static workflows we saw how to create builds using Traceur that can run in the browser and NodeJS, but the problem with these workflows in the browser is that we have no way to load new modules after the initial page load unless we use an AMD loader.

The ES6 Module Specification defines a System loader for the browser (supported in IE8+, and IE9+ if using additional ES6 features), that we can actually polyfill to behave just like the spec using the ES6 Module Loader Polyfill, coming to 7.4KB minified and gzipped, suitable for production.

With some extension libraries, we can make this loader behave just like an AMD loader including support for loading AMD, CommonJS and global scripts as well as other features such as map config and plugins.

Dynamic Workflow 1: Loading ES6 Dynamically in the Browser

We begin by downloading the ES6 Module Loader polyfill and Traceur (es6-module-loader.js, traceur.js see the Getting Started section for the links) and including them in our page.

Say we have a directory of ES6 module files app (our same example has app/app and app/module where app/app imports from ./module), we can import the ES6 and transpile it in the browser dyanmically with:

<!doctype html>
  <script src="traceur.js"></script>
  <script src="es6-module-loader.js"></script>
  <script>
    System.import('app/app').then(function(app) {
      // app is now the Module object with exports as getters
    });
  </script>

The System loader uses ES6 promises to get the module value. Then that is all there is to it. Now the console log statements would match the previous example.

Loading modules separately and transpiling ES6 in the browser is not suitable for production, that is why we have dynamic workflows 2, 3 and 4.

Dynamic Workflow 2: Loading with SystemJS

The 4.6KB (minified and gzipped) SystemJS loader extension library provides compatibility layers to load any module format (ES6, AMD, CommonJS, global scripts) dynamically in the browser. It also comes with map config, and a plugin system like RequireJS as well as various other features.

To use SystemJS, we include both system.js (see the getting started guide for links) and es6-module-loader.js.

In this example, we'll just load an AMD module:

module.js:

  define(function() {
    return 'This is AMD';
  });

We could equally have written a CommonJS or global module, SystemJS detects the format automatically.

We then load this with:

<!doctype html>
  <script src="es6-module-loader.js"></script>
  <script src="system.js"></script>
  <script>
    System.import('module').then(function(module) {
      console.log(module);
    });
  </script>

In this way, SystemJS can be used as an AMD-style loader.

Dynamic Workflow 3: Creating a SystemJS Bundle with Traceur

To create a production bundle with Traceur that SystemJS can understand, we can use the modules=instantiate option.

In future this module output will be designed to support circular references, making it the most suitable output for ES6 conversion in existing browsers. We're working on this currently.

This creates a full bundle for all the dependencies of an ES6 module entry point, that will work with SystemJS.

To create the bundle (using the same two module example):

  traceur --out app-build.js app/app --modules=instantiate

This creates the file app-build.js from the tree of app/app.js.

We can then load this bundle after SystemJS and have it populate the module registry correctly. If we've compiled ES6, we need to also include the Traceur runtime.

<!doctype html>
  <!-- only use Traceur runtime if using other ES6 features -->
  <script src="traceur-runtime.js"></script> 

  <script src="es6-module-loader.js"></script>
  <script src="system.js"></script>
  <script src="app-build.js"></script>
  <script>
    System.import('app/app');
  </script>

Now when the System.import call is made, app/app has already been populated in the module registry cache by the bundle and no XHR requests are created.

Dynamic Workflow 4: Creating Custom Bundles for all Module Formats

This workflow is the most flexible, and the recommended approach for bundling ES6 modules currently.

The SystemJS Builder project provides a programattic API for creating custom bundles.

For example, if we have a dependency tree like the following:

  app
   - app.js (ES6)
  lib
   - jquery.js (AMD)
   - underscore.js (AMD)
   - bootstrap.js (global)

Say I want to build everything into a single build file.

For this I can do:

  npm install systemjs-builder
  builder.build('app', {
    /* SystemJS Configuration Here */
    baseURL: 'app',
    paths: ...
    map: ...
  }, 'buildfile.js')
  .then(function() {
    console.log('Build complete');
  })
  .catch(function(err) {
    console.error(err);
  })

This will trace the app module, with the provided SystemJS configuration, and build all of its dependencies into a single file, buildfile.js.

There is also an advanced build API for doing custom tree operations such as excluding modules or intersecting shared module trees for tiered bundles.

This build workflow will respect circular references between AMD, CommonJS and globals, with the correct behaviour as defined by the specification.

This workflow API is stable, but the exact output format is still being refined as an area of active development (as of July 2014).

Now we can include this bundle after SystemJS, and have the registry populated correctly:

<!doctype html>
  <!-- only use Traceur runtime if using other ES6 features -->
  <script src="traceur-runtime.js"></script>

  <script src="es6-module-loader.js"></script>
  <script src="system.js"></script>
  <script src="buildfile.js"></script>
  <script>
    System.import('app/app');
  </script>

Summary

For using ES6 modules and module loaders, the general summary would be:

  • For existing CommonJS projects, compile ES6 into CommonJS.
  • For existing AMD projects, compile ES6 into AMD.
  • New ES6 projects can use dynamic ES6 module loaders that load multiple module formats, like the SystemJS loader.
  • It is possible to upgrade an AMD project to use the SystemJS loader, which will support loading AMD as well as ES6, and then use that with ES6 modules compiled into AMD for production.
  • The Traceur instantiate output is a specially designed ES6 compile target that will soon support circular references. Bundling techniques to work alongside this output are the current focus of active development for new ES6 build workflows.

Feedback

Feedback on these workflows is very welcome. Feel free to leave a comment, or get involved in the issue queues of the appropriate projects.