【利发国际手机客户端】|利发国际娱乐城|【唯一入口】

by qntm

Because twenty-five other explanations weren't enough, here I will explain JavaScript promises. This tutorial is intended for people who already understand JavaScript. Previous educational essays of mine are Perl in 2 hours 30 minutes and regular expressions in 55 minutes.

Contents

  1. The problem
  2. The worse problem
  3. Introducing promises
  4. Promise chaining
  5. Error handling in a promise chain
  6. Implementation variations
  7. Summary of promise settling behaviour
  8. Conclusion

The problem

JavaScript is single-threaded. It will only ever do at most one thing at once. There is no concurrency. For example, if x is a global variable of some kind, then it is impossible for the value of x to change between these two lines of code: console.log(x); console.log(x); This is because there are no other threads which could modify x while these two lines are executing.

*

JavaScript is event-based. It maintains a queue of messages. "A function is associated with each message. When the [call] stack is empty, a message is taken out of the queue and processed. The processing consists of calling the associated function (and thus creating an initial stack frame). The message processing ends when the stack becomes empty again." We do not have the ability to inspect the queue, reorder it or remove messages from it. Typically in JavaScript a message takes (or should take) an extremely small amount of time to process, of the order of milliseconds. This ensures that the application continues to appear responsive. If a message takes a very long time to process, or forever: while(true) { } then no other messages can be processed, and the application becomes unresponsive. Commonly this leaves us with no choice but to kill the application entirely (e.g. by closing the browser tab).

*

Now suppose we want to make a HTTP request. What we would like to write is something like this: // BAD CODE DO NOT USE var xhr = new XMLHttpRequest(); xhr.open("GET", "some/resource.json", false); xhr.send(); console.log(xhr.responseText); This is how we make a synchronous HTTP request. The call to xhr.send() blocks until the HTTP request is completed, after which we are free to inspect the XMLHttpRequest object to see the response. But if we do this, then every other message gets blocked up behind this HTTP request. If the server takes a noticeable amount of time to respond, then the application stutters. If the server never responds, then the application locks up forever. Code like this is strongly discouraged due to the negative effect it has on the user experience. Instead, we must write code asynchronously. // GOOD CODE USE THIS INSTEAD var xhr = new XMLHttpRequest(); xhr.addEventListener("load", function() { console.log(this.responseText); }); xhr.open("GET", "some/resource.json"); xhr.send(); Here, the xhr.send() call returns immediately, and JavaScript continues working, while carrying out the real HTTP request in the background (i.e. on a thread to which we, the JavaScript programmer, do not have programmatic access). This is called non-blocking I/O. Later, when (if) the HTTP request is completed, a new message will be placed in the message queue. The message will be associated with that listener function: function() { console.log(this.responseText); } and, when JavaScript eventually reaches that message in the queue, the function will be called.

*

Now, how can we turn this HTTP request-making code into a callable function? What we want to write in our calling code is something like: var responseText = get("some/resource.json"); But neither of these approaches works: var get = function(resource) { var xhr = new XMLHttpRequest(); xhr.addEventListener("load", function() { return this.responseText; // this value goes nowhere }); xhr.open("GET", resource); xhr.send(); return xhr.responseText; // returns `undefined` }; The solution is callbacks. When calling get, we pass in our URL but also a callback function. When — at some nebulous future time — get has finished its task, it will call that function, passing the result in. var get = function(resource, callback) { var xhr = new XMLHttpRequest(); xhr.addEventListener("load", function() { callback(this.responseText); }); xhr.open("GET", resource); xhr.send(); }; Usage: get("some/resource.json", function(responseText) { console.log(responseText); }); This is called continuation-passing style. As a general pattern, this works well, but it is not very pretty. It means that all later code has to be placed inside, or called from, that callback.

*

Interestingly, this pattern can even find use in functions which would not normally be asynchronous. A function like: var parseJson = function(json) { var obj = JSON.parse(json); return obj; }; becomes: var parseJson = function(json, callback) { setTimeout(function() { var obj = JSON.parse(json); callback(obj); }, 0); }; Here, setTimeout() is simply putting a message on the queue right away, without any delay. Although the interval specified is 0 milliseconds, this does not mean that function() { var obj = JSON.parse(json); callback(obj); } will be invoked immediately; other messages which have been queued up in the meantime will be handled first. We might do this if we are carrying out a long single calculation and we want to break it up into smaller messages. Note that parseJson no longer explicitly returns anything, which is another way of saying that it returns undefined. The same is true of get and any other function employing this pattern.

*

This pattern of callbacks becomes troublesome when we need to carry out several tasks in sequence: get("some/resource.json", function(responseText) { parseJson(responseText, function(obj) { extractFnord(obj, function(fnord) { console.log(fnord); }); }); }); Note the increasingly severe indentation. This is called callback hell.

The worse problem

How do we handle errors in this scenario? There are several ways to do this, but they both tend to make the situation even less readable.

Asynchronous error handling technique 1

One technique for handling errors is this: var get = function(resource, callback) { var xhr = new XMLHttpRequest(); xhr.addEventListener("load", function() { if(this.status === 200) { callback(undefined, this.responseText); } else { callback(Error()); } }); xhr.addEventListener("error", function() { callback(Error()); }); xhr.open("GET", resource); xhr.send(); }; And: var parseJson = function(json, callback) { setTimeout(function() { try { var obj = JSON.parse(json); callback(undefined, obj); } catch(e) { callback(e); } }, 0); }; Here we pass two arguments to the callback, the first of which is an error. Customarily, if nothing goes wrong, then err is falsy. Usage: get("some/resource.json", function(err1, responseText) { if(err1) { console.error(err1); return; } parseJson(responseText, function(err2, obj) { if(err2) { console.error(err2); return; } extractFnord(obj, function(err3, fnord) { if(err3) { console.error(err3); return; } console.log(fnord); }); }); }); This approach is seen very commonly. As a prime example, take a look at the fs module in Node.js, which carries out filesystem operations, another form of I/O. Compare this blocking I/O function: var str = fs.readFileSync("example.txt", "utf8"); console.log(str); with this non-blocking version: fs.readFile("example.txt", "utf8", function(err, str) { if(err) { console.error(err); return; } console.log(str); });

Asynchronous error handling technique 2

The other way to handle errors is to pass two callbacks into the calculation. One is for success, and the other is for failure: var get = function(resource, callback, errback) { var xhr = new XMLHttpRequest(); xhr.addEventListener("load", function() { if(this.status === 200) { callback(this.responseText); } else { errback(Error()); } }); xhr.addEventListener("error", function() { errback(Error()); }); xhr.open("GET", resource); xhr.send(); }; And: var parseJson = function(json, callback, errback) { setTimeout(function() { try { var obj = JSON.parse(json); callback(obj); } catch(e) { errback(e); } }, 0); }; Usage: get("some/resource.json", function(responseText) { parseJson(responseText, function(obj) { extractFnord(obj, function(fnord) { console.log(fnord); }, function(err3) { console.error(err3); }); }, function(err2) { console.error(err2); }); }, function(err1) { console.error(err1); }); This is marginally better, but still pretty ghastly. Both approaches have drawbacks and some inflexibility, as well as bloating argument lists. There must be a better way!

Introducing promises

Let's do some refactoring. Rewrite get like so: var get = function(resource) { return new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.addEventListener("load", function() { if(this.status === 200) { resolve(this.responseText); } else { reject(Error()); } }); xhr.addEventListener("error", function() { reject(Error()); }); xhr.open("GET", resource); xhr.send(); }); }; And similarly parseJson: var parseJson = function(json) { return new Promise(function(resolve, reject) { setTimeout(function() { try { var obj = JSON.parse(json); resolve(obj); } catch(e) { reject(e); } }, 0); }); }; The asynchronous functions no longer accept callbacks, either for success or for failure. But they do return something now. The object returned is called a promise. The promise represents the task which get or parseJson (or extractFnord) has promised to do. It has three states: pending, fulfilled or rejected. A promise starts out pending. The inner function(resolve, reject) { ... }, which we supply, is called the executor callback. The promise calls this function, passing in two arguments, resolve and reject. When the task has been done, we call resolve to fulfill the promise i.e. mark the task as completed. Or, if the task has failed, we call reject to reject the promise i.e. mark the task as failed. A promise which is fulfilled or rejected is called settled. By interacting with this promise object, we can register success and error callbacks to be called when it settles. But first, some bullet points!
  1. Once settled, a promise can never change state again.
  2. If we call resolve() twice, the second call will be silently ignored.
  3. If we call reject() twice, the second call will be silently ignored.
  4. If we call resolve() and then call reject(), or vice-versa, the second call will be silently ignored. All three/four of these cases are probably mistakes, though.
  5. If we neglect to call either resolve() or reject(), the promise will never settle. Again, this is probably a mistake.
  6. A fulfilled promise has a value. Call resolve(value) to fulfill the promise with a value. The value can be anything except another promise.
  7. If we try to fulfill a promise with a value which is a second promise, the first promise instead settles the same way, and with the same value or error, as the second promise. This is crucially important, but it is also a little difficult to grasp in the abstract. Concrete examples will appear in just a minute.
  8. It's not possible to change the value once set.
  9. A rejected promise has an error. Call reject(err) to reject the promise with an error. The error can be anything, even another promise, but is commonly an Error.
  10. It's not possible to change the error once set.
  11. Calling resolve() or reject() with no arguments is equivalent to calling them with a single argument, undefined.
  12. Extra arguments passed to resolve() or reject(), past the first one, will be silently ignored.
  13. The value returned by the executor callback, if any, goes nowhere, and does nothing.
  14. The value of this inside the executor callback is undefined — provided that we are using strict mode, which we always should be.
  15. throwing a value is equivalent to calling reject() with that value, so no need for catch(e) { reject(e); }.
Most of these little guarantees just serve to make promises simpler and more robust in their behaviour. Notice that there were no such guarantees in our original asynchronous functions definitions; there was nothing to stop us from, say, calling callback twice with two different values. Promises protect us from such weirdness and from the boilerplate code which would be required to manually handle such weirdness.

And also introducing then()

Now here's a very important point: there is no way to directly inspect the current state of a promise, or the value it fulfilled with (if any), or the error it rejected with (if any). Instead, we call then(callback, errback) on a promise to register a success callback and an error callback. And so our usage now looks like this: get("some/resource.json").then(function(responseText) { parseJson(responseText).then(function(obj) { extractFnord(obj).then(function(fnord) { console.log(fnord); }, function(err3) { console.error(err3); }); }, function(err2) { console.error(err2); }); }, function(err1) { console.error(err1); }); This still isn't great, but we're gradually getting closer to something good. First, more bullet points:
  1. If and when the promise (such as get("some/resource.json")) fulfills with a value, the success callback will be called with that value.
  2. If and when the promise rejects with an error, the error callback will be called with that error.
  3. Since a promise cannot fulfill twice, the success callback will only ever be called at most once.
  4. Since a promise cannot reject twice, the error callback will only ever be called at most once.
  5. Since a promise cannot fulfill and reject, for any given then(callback, errback) pair, either the success callback or the error callback will be called (or neither), never both.
  6. The second argument to then() is optional; if we omit it and the promise rejects, nothing happens.
  7. The first argument to then() is also optional; if we omit it (i.e. then(undefined, errback)) and the promise fulfills, nothing happens.
  8. As a shorthand for then(undefined, errback), we can use catch(errback).
  9. The success or error callbacks, if called, will always be passed exactly one argument.
  10. The success or error callbacks, if called, will be called in a separate message, not synchronously when the promise settles.
  11. The value of this inside the success and error callbacks is undefined — provided that we are using strict mode, which we always should be.
  12. We don't actually have to call then(); the executor callback will still be called and whatever task was listed there will still be carried out, or at least attempted.
  13. We can call then() on the same promise multiple times; when the promise settles, all the registered success or error callbacks will be called in the order in which they were registered.
  14. It is totally okay to call then() on a promise after it has already been settled; the appropriate callback will be invoked right away. (Likewise, in the executor callback, it's fine to call resolve() or reject() before any success or error callbacks have been registered.) On that topic:
  15. We can create a promise which has already been fulfilled, using Promise.resolve("foo").
  16. We can create a promise which has already been rejected, using Promise.reject("bar").
  17. then() returns a new promise. This enables us to chain promises together, setting up a sequence of asynchronous operations. This is the most important feature of promises and warrants a full section...

Promise chaining

Calling then(callback, errback) on a promise returns a new promise. The way in which the new promise settles depends on two things:
  • The way in which the first promise settles.
  • What happens inside the success or error callback.
  • Here are some examples.
    1. This promise: Promise.resolve("foo") fulfills with value "foo".
    2. But this promise: Promise.resolve("foo").then(function(str) { return str + str; }) fulfills with value "foofoo". This gives us a basic ability to synchronously transform fulfilled values.
    3. This promise: Promise.resolve("foo").then(function(str) { throw str + str; }) rejects with error "foofoo".
    4. This promise: Promise.reject("bar") rejects with error "bar".
    5. This promise: Promise.reject("bar").catch(function(err) { return err + err; }) fulfills with value "barbar". This is because the error callback is intended to be an error handler. If the error callback returns normally, then it is assumed that the error has been handled gracefully, and the returned value is the value with which to fulfill the new promise.
    6. If our error callback is unable to handle an error gracefully, correct behaviour is to throw a new error. This promise: Promise.reject("bar").catch(function(err) { throw err + err; }) rejects with value "barbar". Alternatively, if there's no way that this error could be handled gracefully at this time, we could simply omit the error callback entirely and allow the error to continue to propagate.

    *

    In a success or error callback, we can return or throw nearly any value we like. If we don't return anything, this is the same as returning undefined, so the fulfilled value of the new promise is undefined, which is fine. There's one special case, which we may remember from earlier:

    If we fulfill a promise with a second promise, the first promise settles the same way as the second

    This applies no matter what method we use to fulfill that first promise, be it Promise.resolve(): Promise.resolve("foo") // fulfills with "foo" Promise.reject("bar") // rejects with "bar" Promise.resolve(Promise.resolve("foo")) // fulfills with "foo" Promise.resolve(Promise.reject("bar")) // rejects with "bar" Promise.resolve(Promise.resolve(Promise.resolve("foo"))) // fulfills with "foo" Promise.resolve(Promise.resolve(Promise.reject("bar"))) // rejects with "bar" // and so on Or the executor callback: new Promise(function(resolve, reject) { resolve("foo"); }) // fulfills with "foo" new Promise(function(resolve, reject) { reject("bar"); }) // rejects with "bar" new Promise(function(resolve, reject) { resolve(Promise.resolve("foo")); }) // fulfills with "foo" new Promise(function(resolve, reject) { resolve(Promise.reject("foo")); }) // rejects with "bar" // and so on Or a success or error callback: Promise.resolve().then(function() { return "foo"; }) // fulfills with "foo" Promise.resolve().then(function() { throw "bar"; }) // rejects with "bar" Promise.resolve().then(function() { return Promise.resolve("foo"); }) // fulfills with "foo" Promise.resolve().then(function() { return Promise.reject("bar"); }) // rejects with "bar" // and so on (Note that it's totally okay to reject a promise with a second promise: Promise.reject(Promise.resolve("foo")) // rejects with `Promise.resolve("foo")` new Promise(function(resolve, reject) { reject(Promise.resolve("foo")); }); // rejects with `Promise.resolve("foo")` Promise.resolve().then(function() { throw Promise.resolve("foo"); }) // rejects with `Promise.resolve("foo")` // and so on But this is a rather odd thing to do...)

    *

    Why is this so significant? Because it means that we can asynchronously transform values as well. Which allows us to suddenly turn this code: get("some/resource.json").then(function(responseText) { parseJson(responseText).then(function(obj) { extractFnord(obj).then(function(fnord) { console.log(fnord); }, function(err3) { console.error(err3); }); }, function(err2) { console.error(err2); }); }, function(err1) { console.error(err1); }); into this: get("some/resource.json").then(function(responseText) { return parseJson(responseText); }).then(function(obj) { return extractFnord(obj); }).then(function(fnord) { console.log(fnord); }).catch(function(err) { console.error(err); }); And boom! We're out of callback hell!

    *

    Let's break our new asynchronous code down. There are five promises in the main chain.
    1. The first: get("some/resource.json") fulfills with responseText.
    2. The second: get("some/resource.json").then(function(responseText) { return parseJson(responseText); }) settles the same way as parseJson(responseText) does; that is, fulfills with the parsed JSON object, obj.
    3. The third: get("some/resource.json").then(function(responseText) { return parseJson(responseText); }).then(function(obj) { return extractFnord(obj); }) settles the same way as extractFnord(obj) does; that is, fulfills with fnord.
    4. The fourth: get("some/resource.json").then(function(responseText) { return parseJson(responseText); }).then(function(obj) { return extractFnord(obj); }).then(function(fnord) { console.log(fnord); }) fulfills with the value returned from that last success callback, i.e. undefined.
    5. And the fifth: get("some/resource.json").then(function(responseText) { return parseJson(responseText); }).then(function(obj) { return extractFnord(obj); }).then(function(fnord) { console.log(fnord); }).catch(function(err) { console.error(err); }); fulfills the same way as the fourth does; that is, fulfills with value undefined.

    *

    One more thing. Since the callback functions are guaranteed to be called with only a single argument, and the value of this passed will be undefined, and our intermediate functions parseJson and extractFnord don't use this internally, our code may be simplified even further: get("some/resource.json") .then(parseJson) .then(extractFnord) .then(function(fnord) { console.log(fnord); }).catch(function(err) { console.error(err); }); In some browsers, console.log and console.error aren't sensitive to the value of this either, so we can even go as far as: get("some/resource.json") .then(parseJson) .then(extractFnord) .then(console.log) .catch(console.error); Amazing! To take maximum advantage of this pattern, write functions (and methods!) which
  • Accept only a single argument
  • Do not use this (or, use this but also use bind() to fix its value)
  • Return a promise
  • *

    So here's a fun edge case. If we fulfill a promise with a second promise, the first promise settles the same way as the second. What if the second promise is the first promise? var p = new Promise(function(resolve, reject) { setTimeout(function() { resolve(p); }, 0); }); (Note that setTimeout must be used here, since the executor callback is called synchronously at Promise construction time, at which time the value of p is still undefined.) Answer: this promise rejects with a TypeError because of the cycle that has been introduced. Let's use this to segue into the topic of error handling.

    Error handling in a promise chain

    If a promise rejects, execution passes to the next available error callback. To demonstrate how this works, we'll start with a basic promise chain: Promise.resolve("foo").then(function(str) { return str + str; }).then(function(str) { console.log(str); }, function(err) { console.error(err); }); and see what happens if we introduce errors — which is to say, cause promises to reject — at various points. This code: Promise.reject("bar").then(function(str) { return str + str; }).then(function(str) { console.log(str); }, function(err) { console.error(err); }); hits none of the success callbacks, and immediately errors out printing "bar". This code: Promise.resolve("foo").then(function(str) { throw str + str; }).then(function(str) { console.log(str); }, function(err) { console.error(err); }); hits the first success callback, then errors out printing "foofoo". The second success callback is not hit. And finally, this code: Promise.resolve("foo").then(function(str) { return str + str; }).then(function(str) { throw str; }, function(err) { console.error(err); }); prints nothing at all! Remember: when we use then(callback, errback), either the success callback or the error callback will be called, never both. errback does not handle exceptions thrown by callback. callback and errback are both intended to handle the outcome from the previous promise, not from each other. Because of this potential for "leaking" an error, I think it is good practice to never call then(callback, errback), passing in both callbacks. It's safer to always use then(callback), passing in only one callback, or, when handling errors at the tail end of a chain, to use catch(errback): Promise.resolve("foo").then(function(str) { return str + str; }).then(function(str) { throw str; }).catch(function(err) { console.error(err); }); And in general, we should always conclude a promise chain with a catch() call, because otherwise errors in the chain will disappear and never be detected. Of course, if an exception is thrown during an error callback, then we may be out of luck entirely, but that's a standing problem with all of error handling...

    Implementation variations

    Throughout this document we have been using the native Promise implementation which is present in many JavaScript engines. This is a relatively new feature of JavaScript and does not have universal support; in particular, it is not available in Internet Explorer. However, there are numerous third-party implementations of promises which work in a basically identical way, such as Q and Bluebird. All these implementations conform to a technical specification called Promises/A+. This specification only really specifies the behaviour of the then() method. The then() method will work identically in all conforming implementations. Everything else is left up to implementers. For example, the APIs for:
    1. constructing a new promise to carry out a particular task (here new Promise(function(resolve, reject){}))
    2. creating pre-fulfilled promises (here Promise.resolve("foo"))
    3. creating pre-rejected promises (here Promise.reject("bar"))
    4. easily adding an error callback with no success callback (here catch(function(){}))
    are not specified by Promises/A+. The native Promise implementation specified in ES6 works like this, and other implementations generally work similarly, but these APIs are not necessarily universal. Different implementations are also at liberty to offer whatever additional functionality they wish. Promise offers two other methods worth mentioning, Promise.all() and Promise.race(), both of which accept an array of promises run "in parallel". Promise.all() fulfills with an array containing the fulfilled values from all the inner promises, Promise.race() fulfills with the value of the first inner promise to fulfill. Other implementations usually offer equivalent functionality and often offer much more functionality.

    Summary of promise settling behaviour

    Promise Attempted settle method Value/error New state Value/error
    new Promise(function(resolve, reject) { resolve( "foo" ); }) Fulfilled "foo"
    new Promise(function(resolve, reject) { resolve( Promise.resolve("X") ); }) Fulfilled "X"
    new Promise(function(resolve, reject) { resolve( Promise.reject("Y") ); }) Rejected "Y"
    new Promise(function(resolve, reject) { reject( "foo" ); }) Rejected "foo"
    new Promise(function(resolve, reject) { reject( Promise.resolve("X") ); }) Rejected Promise.resolve("X")
    new Promise(function(resolve, reject) { reject( Promise.reject("Y") ); }) Rejected Promise.reject("Y")
    new Promise(function(resolve, reject) { return  "foo" ; }) Pending none
    new Promise(function(resolve, reject) { return  Promise.resolve("X") ; }) Pending none
    new Promise(function(resolve, reject) { return  Promise.reject("Y") ; }) Pending none
    new Promise(function(resolve, reject) { throw  "foo" ; }) Rejected "foo"
    new Promise(function(resolve, reject) { throw  Promise.resolve("X") ; }) Rejected Promise.resolve("X")
    new Promise(function(resolve, reject) { throw  Promise.reject("Y") ; }) Rejected Promise.reject("Y")
    Promise.resolve().then(function() { resolve( "foo" ); }) Rejected ReferenceError("resolve is not defined")
    Promise.resolve().then(function() { resolve( Promise.resolve("X") ); }) Rejected ReferenceError("resolve is not defined")
    Promise.resolve().then(function() { resolve( Promise.reject("Y") ); }) Rejected ReferenceError("resolve is not defined")
    Promise.resolve().then(function() { reject( "foo" ); }) Rejected ReferenceError("reject is not defined")
    Promise.resolve().then(function() { reject( Promise.resolve("X") ); }) Rejected ReferenceError("reject is not defined")
    Promise.resolve().then(function() { reject( Promise.reject("Y") ); }) Rejected ReferenceError("reject is not defined")
    Promise.resolve().then(function() { return  "foo" ; }) Fulfilled "foo"
    Promise.resolve().then(function() { return  Promise.resolve("X") ; }) Fulfilled "X"
    Promise.resolve().then(function() { return  Promise.reject("Y") ; }) Rejected "Y"
    Promise.resolve().then(function() { throw  "foo" ; }) Rejected "foo"
    Promise.resolve().then(function() { throw  Promise.resolve("X") ; }) Rejected Promise.resolve("X")
    Promise.resolve().then(function() { throw  Promise.reject("Y") ; }) Rejected Promise.reject("Y")

    Conclusion

    I find promises to be heck of complicated and it wasn't until I sat down and tried to write this that I realised just how poorly I understood them. Not pictured here is the lengthy interlude where I gave up and, as a learning exercise, attempted to read, understand and implement the Promises/A+ specification myself, which nearly caused my head to explode. Anyway, I think I have a pretty good handle on them now, and I hope you do too.

    Back to Things Of Interest

    利发国际手机客户端