Over the last few years, developers have been relentlessly moving their server-side sites to the client-side on the premise that the page performance would be improved.
However, this may not be enough. Have you ever considered that your site may be downloading more stuff than is being actually used? Meet Lazy-loading, a design pattern about deferring the initialization(loading/fetching/allocation) of a resource (code/data/asset) until the point at which it is needed.
At the same time, ES2015 is already production-ready through transpilers such as Babel. Now you no longer need to fight the AMD vs CommonJS war - described on my article The mind-boggling universe of JavaScript Module strategies - since you can simply write ES2015 modules and have them transpiled and delivered to the browser, while supporting your existing CommonJS or AMD modules.
This article demonstrates how to load ES2015 modules synchronously (during the page load) and asynchronously (performing lazy-loading) using System.js.
When developing JavaScript code to be executed on the browser, you always have to decide WHEN you want it to be executed.
There is always some chunk of code that must run during the page load, as for instance the structural setup of an SPA using frameworks such as Angular, Ember, Backbone or React. Such code must be referenced on the main HTML document returned to the browser after a page request, most likely through one or more <script>
tags.
On the other hand, you might have some more chunks of code from features that should only be executed if certain triggering conditions happen. Classical examples are:
This way, for a given feature like those ones above, if its triggering condition never happens, its chunk of code won't ever be executed. Hence, that chunk of code is definitely not needed during the page load and it can be deferred.
In order to defer it, you simply need to leave that code out of the chunk of code which gets downloaded and executed during the page load - so that it would only be downloaded and executed on demand, when its triggering condition happens for the first time.
This approach of asynchronously loading deferred code, or lazy-loading, plays an important role in improving the page performance, in terms of reducing the page load time and the Speed Index.
In order to learn more about the Speed Index and the performance impacts on Page load vs Lazy-loading, check out my article: Leveling up: Simple Steps to Optimize the Critical Rendering Path.
The AMD standard was created for asynchronous module loading on the browser, being one of the first successful alternatives to the spaghetti mess known as global JavaScript files scattered around your page. According to the Require.js documentation:
The AMD format comes from wanting a module format that was better than today’s “write a bunch of script tags with implicit dependencies that you have to manually order” and something that was easy to use directly in the browser.
It is based on empowering the Module Design Pattern with a module loader, dependency injection, alias resolution and asynchronous capabilities. One of it main usages is to perform lazy-loading of modules.
Despite being a formidable idea, it brings some inherent complexity: namely, the need to understand runtime module timelines, which was previously unnecessary. This means that developers need to know when each asynchronous module is expected to do its work.
By failing to understand this, developers got into situations that may work sometimes and may not work some other times, due to race conditions, which can be quite difficult to debug. Because of such things, AMD lost quite a bit of its momentum and traction, unfortunately.
In order to learn more about AMD pitfalls, check out Moving Past RequireJS.
Before we go any further, let's go over ES2015 modules. If you are already familiar with them, here's a quick refresher.
Modules have been finally adopted as an official part of the JavaScript language in ES2015. They are powerful yet simple to grasp, standing on the shoulders of the CommonJS modules giants.
Basically, a ES2015 module will live in its own file. All its "globals" variables will be scoped to just this file. Modules can export data and also import other modules.
Export a ES2015 module's interface through the keyword export
before each item you want to export (a variable, function or class). In the following example, we are exporting Dog
and Wolf
:
// zoo.js
var getBarkStyle = function(isHowler) {
return isHowler? 'woooooow!': 'woof, woof!';
};
export class Dog {
constructor(name, breed) {
this.name = name;
this.breed = breed;
}
bark() {
return `${this.name}: ${getBarkStyle(this.breed === 'husky')}`;
};
}
export class Wolf {
constructor(name) {
this.name = name;
}
bark() {
return `${this.name}: ${getBarkStyle(true)}`;
};
}
Let's see how to import this module in a Mocha/Chai unit test, using the syntax import <object> from <path>
. As for <object>
we can pick which elements we want to import - something called named imports. We can then decide to just import expect
from chai
as well as Dog
and Wolf
from Zoo
. By the way, this syntax of named imports resembles another handy ES2015 feature - destructuring objects.
// zoo_spec.js
import { expect } from 'chai';
import { Dog, Wolf } from '../src/zoo';
describe('the zoo module', () => {
it('should instantiate a regular dog', () => {
var dog = new Dog('Sherlock', 'beagle');
expect(dog.bark()).to.equal('Sherlock: woof, woof!');
});
it('should instantiate a husky dog', () => {
var dog = new Dog('Whisky', 'husky');
expect(dog.bark()).to.equal('Whisky: woooooow!');
});
it('should instantiate a wolf', () => {
var wolf = new Wolf('Direwolf');
expect(wolf.bark()).to.equal('Direwolf: woooooow!');
});
});
If you only have one item to export, you can use export default
to export your item as an object instead of exporting a container object with your item inside:
// cat.js
export default class Cat {
constructor(name) {
this.name = name;
}
meow() {
return `${this.name}: You gotta be kidding that I'll obey you, right?`;
}
}
Importing default modules is simpler, as object destructuring is no longer needed. You can simply directly import the item from the module.
// cat_spec.js
import { expect } from 'chai';
import Cat from '../src/cat';
describe('the cat module', () => {
it('should instantiate a cat', () => {
var cat = new Cat('Bugsy');
expect(cat.meow()).to.equal('Bugsy: You gotta be kidding that I\'ll obey you, right?');
});
});
In order to learn more about ES2015 modules, check out Exploring ES6 book — Modules.
As surprising as it may be, ES2015 doesn't actually have a module loader specification. There was a popular proposal for a dynamic module loader - es6-module-loader - which inspired System.js. This proposal has been retreated, but there is both a new Loader spec in the works by WhatWG, and the Dynamic Import spec by Domenic Denicola.
Nevertheless, System.js is today one of the most frequently used module loader implementations which support ES2015. It supports ES2015, AMD, CommonJS and global scripts in the browser and NodeJS. It provides an asynchronous module loader (to pair with Require.js) and ES2015 transpiling through Babel, Traceur or Typescript.
System.js implements asynchronous module loading using a Promises-based API. This is a very powerful and flexible approach, since promises can be chained and combined: so for instance, if you want to load multiple modules in parallel, you can use Promises.all
and just fire your listener when all the promises have been resolved.
Lastly, the Dynamic Import spec is getting a lot of traction and has been incorporated on Webpack 2. You can check out how it's going to work on Webpack 2's guide for Code splitting with ES2015. It's also inspired in System.js so the transition would be quite simple.
In order to illustrate the loading of modules in both synchronous and asynchronous fashion I've created a sample project, which will synchronously load our Cat
module during the page load, and lazy-load the Zoo
module once the user clicks on a button. The code is available on my Github project lazy-load-es2015-systemjs.
Let's have a look at the main chunk of code which is loaded during the page load, our main.js
.
First, notice how it performs synchronous loading of Cat
through import
. After that, it creates an instance of Cat
, invokes its method meow()
and append the result to the DOM:
// main.js
// Importing Cat module synchronously
import Cat from 'cat';
// DOM content node
let contentNode = document.getElementById('content');
// Rendering cat
let myCat = new Cat('Bugsy');
contentNode.innerHTML += myCat.meow();
Lastly, notice the asynchronous loading of Zoo
through System.import('zoo')
, and finally, the instances of Dog
and Wolf
invoking their method bark()
and again appending the results to the DOM:
// Button to lazy load Zoo
contentNode.innerHTML += `<p><button id='loadZoo'>Lazy load <b>Zoo</b></button></p>`;
// Listener to lazy load Zoo
document.getElementById('loadZoo').addEventListener('click', e => {
// Importing Zoo module asynchronously
System.import('zoo').then(Zoo => {
// Rendering dog
let myDog = new Zoo.Dog('Sherlock', 'beagle');
contentNode.innerHTML += `${myDog.bark()}`;
// Rendering wolf
let myWolf = new Zoo.Wolf('Direwolf');
contentNode.innerHTML += `<br/>${myWolf.bark()}`;
});
});
When the page is first loaded, the only modules which are loaded are Cat
and Main
:
Once the user clicks on the button, the Zoo
module is then loaded:
Mastering the art of keeping the page load close to the minimal necessary and lazy-loading deferrable modules can definitely improve your page performance. AMD and CommonJS paved the way for ES2015 modules, which are available to you right now via transpilers. You can start loading your ES2015 modules with System.js, or with the Dynamic Imports spec over Webpack 2, while the official solution is not yet released.
For more information on this, check out the presentation Lazy Loading ES2015 modules in the browser given by the author at conferences such as: Front End Design Conference (St. Petersburg, FL), DevCon5 (New York, NY) and Abstractions (Pittsburgh, PA).