Easy asynchrony with ES6
Promises and generators can be used together to describe complex asynchronous flow in a simple, synchronous way.
Chrome supports both native Promises (version 33) and ES6 generators but it's necessary to enable Experimental JavaScript at chrome://flags/#enable-javascript-harmony
(version 36).
In Firefox both promises and generators are supported by default (version 29).
The function below converts a generator into a promise resolving state machine. In this generator each yield
works as C♯'s await
.
function async(generatorFunction) {
return function(/*...args*/) {
var generator = generatorFunction.apply(this, arguments);
return new Promise(function(resolve, reject) {
function resume(method, value) {
try {
var result = generator[method](value);
if (result.done) {
resolve(result.value);
} else {
result.value.then(resumeNext, resumeThrow);
}
} catch (e) {
reject(e);
}
}
var resumeNext = resume.bind(null, 'next');
var resumeThrow = resume.bind(null, 'throw');
resumeNext();
});
};
}
These two helper functions will convert DOM events and timeouts into promises that resolve when an event occurs or a specified amount of time passes.
function event(object, name) {
return new Promise(function(resolve) {
object.addEventListener(name, resolve);
});
}
function wait(millis) {
return new Promise(function(resolve) {
setTimeout(resolve, millis);
});
}
The run
function will print the status of a given promise when it is fulfilled.
function run(promise) {
promise.then(
function(value) {
print('Promise fulfilled with ' + value);
},
function(error) {
print('Promise rejected with ' + error);
}
);
}
The proc
generator below illustrates a simple asynchronous flow.
To run the example your browser has to support:
- Native promises
- ES6 generators
var proc = async(function* (id) {
try {
print('Start: ' + id);
yield wait(1000);
print('Press Continue executing 3 times.');
continueBtn.disabled = false;
var e;
for (var i = 1; i <= 3; i++) {
e = yield event(continueBtn, 'click');
continueBtn.value = 'Continue executing (' + i + ')';
}
continueBtn.disabled = true;
print('Event: ' + e);
return 'End';
} catch (err) {
return 'Error ' + err;
} finally {
print('End of generator');
}
});
run(proc(Math.random()));
function print(msg) {
var li = document.createElement('LI');
li.textContent = msg;
output.appendChild(li);
}
The alternative version of the same code written using pure promises.
- Native promises
function procPromises(id) {
return Promise.resolve(null)
.then(function() { print('Start: ' + id); })
.then(function() { return wait(1000); })
.then(function() { print('Press Continue executing.'); })
.then(function() {
continueBtn.disabled = false;
var i = 0;
var inner = function(e) {
continueBtn.value = 'Continue executing (' + i + ')';
if (i > 2) {
return e;
}
return event(continueBtn, 'click').then(function(evt) {
i++;
return evt;
}).then(inner);
};
return inner();
})
.then(function(e) {
e.target.disabled = true;
print('Event: ' + e);
return 'ok';
})
.catch(function(err) { return 'Error ' + err; })
.then(function(result) {
print('End of promise');
return result;
});
}
run(procPromises(Math.random()));
Note that inside the generator it is possible to use loops (for/while
) around asynchronous functions. The pure-promise based approach requires creating complex inner functions to maintain the iteration state.
Generators look like synchronous code and that makes them more readable than the nested callbacks.
Decoupling promises from generators
It is possible to abstract away the asynchronicity or synchronocity of the code and use one generator for both types of the control flow.
For example the Stream
type has a readBytes
method that returns an array of bytes using calls to the readByte
function. Utilizing generators we can reuse the same algorithm for both the synchronous code that deals with in-memory arrays and the asynchronous one that uses Promises to read bytes from the network or files.
function Stream(source) {
this.source = source;
}
Stream.prototype = {
readBytes: function*(num) {
var bytes = [];
for (var i = 0; i < num; i++) {
bytes.push(yield this.source.readByte());
}
return bytes;
}
};
The synchronous ArraySource
can be used as the source for the stream object. ArraySource
uses throw
and return
statements to control the program flow:
function ArraySource(rawBytes, index) {
this.rawBytes = rawBytes;
this.index = index || 0;
}
ArraySource.prototype = {
readByte: function() {
if (!this.hasMoreBytes()) {
throw new Error('Cannot read past the end of the array.');
}
return this.rawBytes[this.index++];
},
hasMoreBytes: function() {
return this.index < this.rawBytes.length;
}
};
Reading bytes from this source using the Stream
object can also be synchronous:
var syncSource = new ArraySource([1, 2, 3, 4, 5]);
// reading a byte from the synchronous source
print(syncSource.readByte());
var stream = new Stream(syncSource);
try {
// reading bytes array synchronously
var bytes = sync(stream.readBytes(4));
print(bytes);
} catch (e) {
print(e);
}
The second option is the FileSourceMock
object that uses Promises to return bytes or report errors:
function FileSourceMock(rawBytes, index) {
this.rawBytes = rawBytes;
this.index = index || 0;
}
FileSourceMock.prototype = {
readByte: function() {
if (!this.hasMoreBytes()) {
var err = Error('Cannot read past the end of the array.');
return Promise.reject(err);
}
return Promise.resolve(this.rawBytes[this.index++]);
},
hasMoreBytes: function() {
return this.index < this.rawBytes.length;
}
};
Reading from this source requires using the asynchronous control flow but the readBytes
method does not need to be modified:
var asyncSource = new FileSourceMock([1, 2, 3, 4, 5]);
// reading a byte from the asynchronous source
asyncSource.readByte().then(function (byte) {
print(byte);
});
var stream = new Stream(asyncSource);
// reading byte array asynchronously
async(stream.readBytes(4)).then(function (bytes) {
print(bytes);
}, function(error) {
print(error);
});
The code above uses two strategies of executing the generator — asynchronous and synchronous.
The asynchronous flow control uses Promises to represent success and failure and the then
method to continue executing generator when the promise is fulfilled.
var async = flow({
resume: function(value, next, abort) {
return Promise.resolve(value).then(next, abort);
},
finish: function(value) {
return Promise.resolve(value);
},
abort: function(error) {
return Promise.reject(error);
}
});
The synchronous flow uses the return
and throw
statements to report the success or the failure. The next
function which represents the flow continuation is executed synchronously passing the value returned by the previous step.
var sync = flow({
resume: function(value, next, abort) {
return next(value);
},
finish: function(value) {
return value;
},
abort: function(error) {
throw error;
}
});
The flow function separates the generator from the type of the control flow.
function flow(controller) {
return function wrap(generator) {
function resume(method, previous) {
var result, value;
try {
result = generator[method](previous);
} catch (e) {
return controller.abort(e);
}
value = result.value;
// wrap inner generators in the same control flow
if (value && typeof value.next === 'function') {
value = wrap(value);
}
if (result.done) {
return controller.finish(value);
} else {
return controller.resume(value, next, abort);
}
}
var next = resume.bind(null, 'next');
var abort = resume.bind(null, 'throw');
return next();
};
}
For a real-world use of this technique see the Low-level Bitcoin article.
Comments
Revisions
- Initial version.
- Split long code fragments into separate sections; change title to Easy asynchrony.
- Use
async
function wrapper instead ofstart
. - Add Decoupling promises from generators section.