NB: This is for promises/A+ v1. The spec has since moved to v1.1. The below is still a good introduction. There are now slides available with an implementation of v1.1.
What we’re going to do is create a promises/A+ implementation based on http://promises-aplus.github.io/promises-spec/. By doing this hopefully we’ll get a deeper understanding of just how promises work. I’ll call this Aplus and put it up on github under https://github.com/rhysbrettbowen/Aplus
First some boilerplate. Let’s make Aplus an object:
Aplus = {};
Promise States
var State = {
PENDING: 0,
FULFILLED: 1,
REJECTED: 2
};
var Aplus = {
state: State.PENDING
};
according to the terminology http://promises-aplus.github.io/promises-spec/#terminology a value can be anything including undefined and a reason is any value that indicates why a promise was rejected. That last definition is a little blurry – can “undefined” indicate why something was rejected? I’m going to say no and only accept non-null values. If anything doesn’t work then I’ll throw an error. So let’s create a “chageState” method that handles the checking for us:
var State = {
PENDING: 0,
FULFILLED: 1,
REJECTED: 2
};
var Aplus = {
state: State.PENDING,
changeState: function(state, value) {
// catch changing to same state (perhaps trying to change the value)
if ( this.state == state ) {
throw new Error("can't transition to same state: " + state);
}
// trying to change out of fulfilled or rejected
if ( this.state == State.FULFILLED ||
this.state == State.REJECTED ) {
throw new Error("can't transition from current state: " + state);
}
// if second argument isn't given at all (passing undefined allowed)
if ( state == State.FULFILLED &&
arguments.length < 2 ) {
throw new Error("transition to fulfilled must have a non null value");
}
// if a null reason is passed in
if ( state == State.REJECTED &&
value == null ) {
throw new Error("transition to rejected must have a non null reason");
}
//change state
this.state = state;
this.value = value;
return this.state;
}
};
Now we’re on to the fun stuff.
Then
then: function( onFulfilled, onRejected ) {
// initialize array
this.cache = this.cache || [];
var promise = Object.create(Aplus);
this.cache.push({
fulfill: onFulfilled,
reject: onRejected,
promise: promise
});
return promise;
}
Resolving
resolve: function() {
// check if pending
if ( this.state == State.PENDING ) {
return false;
}
// for each 'then'
while ( this.cache && this.cache.length ) {
var obj = this.cache.shift();
// get the function based on state
var fn = this.state == State.FULFILLED ? obj.fulfill : obj.reject;
if ( typeof fn != 'function' ) {
fn = function() {};
}
// fulfill promise with value or reject with error
try {
obj.promise.changeState( State.FULFILLED, fn(this.value) );
} catch (error) {
obj.promise.changeState( State.REJECTED, error );
}
}
}
This is a good first pass. It handles the base case for normal functions. The two other cases we need to handle though are when we’re missing a function (at the moment we’re using a blank function but we really need to pass along the value or the reason with the correct state) and when they return a promise. Let’s first tackle the problem of passing along an error or value when we’re missing a function:
resolve: function() {
// check if pending
if ( this.state == State.PENDING ) {
return false;
}
// for each 'then'
while ( this.cache && this.cache.length ) {
var obj = this.cache.shift();
var fn = this.state == State.FULFILLED ? obj.fulfill : obj.reject;
if ( typeof fn != 'function' ) {
obj.promise.changeState( this.state, this.value );
} else {
// fulfill promise with value or reject with error
try {
obj.promise.changeState( State.FULFILLED, fn(this.value) );
} catch (error) {
obj.promise.changeState( State.REJECTED, error );
}
}
}
}
If the function doesn’t exist we’re essentially passing along the state and the value. One thing that hit me when reading through this is that if you are using a onRejected function and you want to pass along the error state to the next promise is you’ll have to throw another error, otherwise the promise will resolve with the returned value. I guess that this is a good thing as you can essentially use onRejected to “fix” errors by doing things like returning a default value.
There is only one thing left in resolving and that’s to handle what happens when a promise is returned. The spec gives an example of how to do this at: http://promises-aplus.github.io/promises-spec/#point-65 so let’s put it in
resolve: function() {
// check if pending
if ( this.state == State.PENDING ) {
return false;
}
// for each 'then'
while ( this.cache && this.cache.length ) {
var obj = this.cache.shift();
var fn = this.state == State.FULFILLED ? obj.fulfill : obj.reject;
if ( typeof fn != 'function' ) {
obj.promise.changeState( this.state, this.value );
} else {
// fulfill promise with value or reject with error
try {
var value = fn( this.value );
// deal with promise returned
if ( value && typeof value.then == 'function' ) {
value.then( function( value ) {
obj.promise.changeState( State.FULFILLED, value );
}, function( reason ) {
obj.promise.changeState( State.REJECTED, error );
});
// deal with other value returned
} else {
obj.promise.changeState( State.FULFILLED, value );
}
// deal with error thrown
} catch (error) {
obj.promise.changeState( State.REJECTED, error );
}
}
}
}
Asynchronous
async: function(fn) {
setTimeout(fn, 5);
}
then: function( onFulfilled, onRejected ) {
// initialize array
this.cache = this.cache || [];
var promise = Object.create(Aplus);
var that = this;
this.async( function() {
that.cache.push({
fulfill: onFulfilled,
reject: onRejected,
promise: promise
});
that.resolve();
});
return promise;
}
and the second should be when the state is changed so add a this.resolve() to the end of the changeState function. Wrap it all in a function that will use Object.create to get you a promise and the final code will look like this:
Final
var Aplus = function() {
var State = {
PENDING: 0,
FULFILLED: 1,
REJECTED: 2
};
var Aplus = {
state: State.PENDING,
changeState: function( state, value ) {
// catch changing to same state (perhaps trying to change the value)
if ( this.state == state ) {
throw new Error("can't transition to same state: " + state);
}
// trying to change out of fulfilled or rejected
if ( this.state == State.FULFILLED ||
this.state == State.REJECTED ) {
throw new Error("can't transition from current state: " + state);
}
// if second argument isn't given at all (passing undefined allowed)
if ( state == State.FULFILLED &&
arguments.length < 2 ) {
throw new Error("transition to fulfilled must have a non null value");
}
// if a null reason is passed in
if ( state == State.REJECTED &&
value == null ) {
throw new Error("transition to rejected must have a non null reason");
}
//change state
this.state = state;
this.value = value;
this.resolve();
return this.state;
},
fulfill: function( value ) {
this.changeState( State.FULFILLED, value );
},
reject: function( reason ) {
this.changeState( State.REJECTED, reason );
},
then: function( onFulfilled, onRejected ) {
// initialize array
this.cache = this.cache || [];
var promise = Object.create(Aplus);
var that = this;
this.async( function() {
that.cache.push({
fulfill: onFulfilled,
reject: onRejected,
promise: promise
});
that.resolve();
});
return promise;
},
resolve: function() {
// check if pending
if ( this.state == State.PENDING ) {
return false;
}
// for each 'then'
while ( this.cache && this.cache.length ) {
var obj = this.cache.shift();
var fn = this.state == State.FULFILLED ?
obj.fulfill :
obj.reject;
if ( typeof fn != 'function' ) {
obj.promise.changeState( this.state, this.value );
} else {
// fulfill promise with value or reject with error
try {
var value = fn( this.value );
// deal with promise returned
if ( value && typeof value.then == 'function' ) {
value.then( function( value ) {
obj.promise.changeState( State.FULFILLED, value );
}, function( error ) {
obj.promise.changeState( State.REJECTED, error );
});
// deal with other value returned
} else {
obj.promise.changeState( State.FULFILLED, value );
}
// deal with error thrown
} catch (error) {
obj.promise.changeState( State.REJECTED, error );
}
}
}
},
async: function(fn) {
setTimeout(fn, 5);
}
};
return Object.create(Aplus);
};
you might have noticed I also put in functions “fulfill” and “reject”. The spec doesn’t say anything about how to manually change the state of a promise. Other names may be used like “fail”, “resolve” or “done” but I’m using “fulfill” and “reject” to keep in line with the specs and what they call their two functions.
Next time
In future I’ll write a bit more about some patterns you can use promises for, like passing around data, making requests in parallel and caching. Promises are really powerful but they also come at a cost so I’ll outline all the pros and cons and what their alternatives are in different situations, but for now hopefully this sheds some light on the internals of how a promise works.
*edit* Looks like the tests https://github.com/promises-aplus/promises-tests don’t like errors thrown so I’ve changed the changeState to instead return the errors, not throw and the tests allow null reasons for errors so I’ve changed that and uploaded to github
Powered by WPeMatico