Making Promises – Building a script loader with ES6 Promises
Promises are making a big splash in the JavaScript world. They are a way of handling asynchronous logic in a way that avoids nested callbacks. In this post I hope to explain why Promises are useful, and how you can start using them today.
Browser support
Promises are being finalised as part of the latest and greatest version of JavaScript called ECMASCript 6 (or ES6 if you’re cool). Native browser support isn’t great but there is out of the box support in Chrome 32+ and Firefox 29+.
For projects that have to run on other browsers Jake Archibald has you covered with the ES6 Promise polyfill. Include it on a page and you can use the proper ES6 Promise syntax on old devices. Beautiful.
Before Promises
Okay, so what do I mean by asynchronous logic? I’m talking about a function call that sets up a listener or callback for something that will happen in the future.
This could be anything that takes time. We could be waiting for the DOM to load, AJAX requests to return, animations to complete, timers to finish, even user interaction. In short, anything that we would use a callback function for.
Traditional JavaScript callbacks work very well up to a point, but we run into problems when we're waiting for multiple conditions to be met. Anyone who has used Node.js will be familiar with the concept of callback hell:
waitForConditionOne(function() {
waitForConditionTwo(function() {
waitForConditionThree(function() {
waitForConditionFour(function() {
waitForConditionFive(function() {
// All conditions are met, run my code now
});
});
});
});
});
The callback pyramid is ugly but at least it’s clear what’s happening. The ultimate goal is to maintain this level of readability without sacrificing good code style.
Better yet, what if there was a way to somehow run all of these steps in parallel, rather than waiting for each to complete in turn. But wait, there is a way!
Enter the Promise
Promises make the whole process of waiting for asynchronous events cleaner and easier by abstracting the problem. Instead of nesting our functions we convert them into Promise objects that can be chained together.
A Promise object is created by running the following:
var myPromise = new Promise(function(resolve, reject) {
// Our condition here
});
A Promise object can exist in one of three states; pending, resolved, or rejected. Since our Promise doesn’t do anything, myPromise
is pending.
The arguments supplied to our anonymous function, resolve
and reject
are functions. The idea is to use these to change the state of our Promise when the asynchronous operation is complete. Let’s look at some code.
In this example we’ll use setTimeout
to wait three seconds before resolving myPromise
with the string 'Hello, world!'
:
var myPromise = new Promise(function(resolve, reject) {
// Wait three seconds before resolving
setTimeout(function() {
resolve('Hello, world');
}, 3000)
});
This works, but it doesn’t do very much. myPromise
changes state but we need to specify what happens when the promise is resolved. To do this we pass a function to the then
method of myPromise
:
var myPromise = new Promise(function(resolve, reject) {
setTimeout(function() {
resolve('Hello, world');
}, 3000)
});
// Do something when the promise gets resolved
myPromise.then(function(value) {
console.log(value);
});
The beauty of using Promises is that we can add multiple then
methods, and at any point.
In the example above we could call myPromise.then
many times with different callback functions and each will be called. It also doesn’t matter what state the Promise is when we add a then
. If the Promise is pending the new callback will be added to the queue; if it’s already resolved the callback will be executed immediately.
While the functions passed to Promise()
are executed in a synchronous manner, functions passed to then()
are executed on the next ‘tick’ – the equivalent of setTimeout(..., 0)
– even if the Promise status is resolved.
Handling rejection
As well as handling success where resolve
is called, Promises can also handle failure with the reject
. Lets look at how we can handle rejection with a more useful example.
Lets say we want to include an external JavaScript library in our page dynamically. In order to use the library we need to wait for it to load, which we can do by listening for it’s load
event to fire. Here’s how that might look using Promises:
function loadScript(url) {
var scriptPromise = new Promise(function(resolve, reject) {
// Create a new script tag
var script = document.createElement('script');
// Use the url argument as source attribute
script.src = url;
// Call resolve when it’s loaded
script.addEventListener('load', function() {
resolve(url);
}, false);
// Add it to the body
document.body.appendChild(script);
});
// Return the Promise
return scriptPromise;
}
// Load a script
loadScript('/path/to/script.js').then(function(value) {
// Resolved
console.log('Script loaded from:', value);
});
This works, but it doesn’t handle the case where the script is not found. To do this we can call the reject
function from the Promise callback, in the same way that we call resolve
on successful completion.
Finally, we can add a second anonymous function argument to the then
method to specify what we do in the event of a rejection. Here’s the updated code:
function loadScript(url, success, failure) {
var scriptPromise = new Promise(function(resolve, reject) {
// Create a new script tag
var script = document.createElement('script');
// Use the url argument as source attribute
script.src = url;
// Call resolve when it’s loaded
script.addEventListener('load', function() {
resolve(url);
}, false);
// Reject the promise if there’s an error
script.addEventListener('error', function() {
reject(url);
}, false);
// Add it to the body
document.body.appendChild(script);
});
scriptPromise
}
// Load a script
loadScript('/path/to/script.js').then(function(value) {
// Resolved
console.log('Script loaded from:', value);
}, function(value) {
// Rejected
console.error('Script not found:', value)
});
There is a catch
method which works similarly to the then
method except only takes a single rejected
callback. Use of this method is not recommended when using the ES6 Promise polyfill due to IE8 and below regarding catch
as a reserved keyword.
Promises in series
Awesome! So what if we want to add more than one external JavaScript library in our example? Well thanks to the fact that our loadScript
function returns a Promise object, we can take advantage of the chainable nature of then
.
Unless a return is specified, the then
method will return a copy of the original Promise allowing multiple then
callbacks to be triggered at once. This can be overridden by returning a new Promise which allows a chain of conditions to be specified in series.
Using the loadScript
function we defined above, we can do the following:
loadScript('/path/to/script-1.js').then(function() {
return loadScript('/path/to/script-2.js');
}).then(function() {
return loadScript('/path/to/script-3.js');
}).then(function() {
return loadScript('/path/to/script-4.js');
}).then(function() {
console.log('Loaded!');
});
This will load each script followed by our final callback, all without resorting to callback hell. If the scripts have to be loaded in strict sequence, then this is unfortunately as far as we can go. However if each script was an independent module and the loading order didn’t matter, we could take things a step further. Lets assume we can.
Going parallel
The final benefit we stand to gain from Promises is to fulfil all of our Promise requests at once. That way we can resolve all of our Promises in parallel instead of waiting for each to resolve in turn.
Promise.all
and Promise.race
are static methods that each take an Array of Promises as an argument, returning a new Promise. Both methods will watch the Array but with one important difference; Promise.all
will resolve when all of the Promises in the Array have resolved, whereas Promise.race
will resolve as soon as any one Promise in the Array has resolved.
Since we want to wait till all of our scripts have loaded, we need to use Promise.all
. We need to refactor our loadScript
function a bit to accommodate this change. For reusability I’ve chosen to rewrite it as a reusable ScriptLoader
object.
function ScriptLoader() {
var promises = [];
this.add = function(url) {
var promise = new Promise(function(resolve, reject) {
var script = document.createElement('script');
script.src = url;
script.addEventListener('load', function() {
resolve(script);
}, false);
script.addEventListener('error', function() {
reject(script);
}, false);
document.body.appendChild(script);
});
promises.push(promise);
};
this.loaded = function(callback) {
Promise.all(promises).then(callback);
};
}
var loader = new ScriptLoader();
loader.add('/path/to/script-1.js');
loader.add('/path/to/script-2.js');
loader.add('/path/to/script-3.js');
loader.add('/path/to/script-4.js');
loader.loaded(function(returned) {
console.log('Loaded!');
});
Wrapping up
And there we have it. If you’d like to read more about Promises and see more code examples, I highly recommend Jake Archibald’s excellent article on HTML5 Rocks called JavaScript Promises: There and back again. There’s also great documentation available on the Mozilla Developer Network, and finally the open Promises/A+ standard itself.