11.2.1 Promises/A+规范
ECMAScript 6增加了对Promises/A+规范的完善支持,即Promise类型。
11.2.2 期约基础
ECMAScript 6新增的引用类型Promise,可以通过new操作符来实例化。
创建新期约时需要传入执行器(executor)函数作为参数(后面马上会介绍),
下面的例子使用了一个空函数对象来应付一下解释器:
let p = new Promise (() => {});setTimeout(console.log, 0, p); // Promise {<pending>}
1.期约状态机
在把一个期约实例传给console.log()时,控制台输出(可能因浏览器不同而略有差异)表明该实例处于待定(pending)状态。
期约是一个有状态的对象,可能处于如下3种状态之一:
❑ 待定(pending)
❑ 兑现(fulfilled,有时候也称为“解决”, resolved)
❑ 拒绝(rejected)
待定(pending)是期约的最初始状态。在待定状态下,期约可以落定(settled)为代表成功的兑现(fulfilled)状态,或者代表失败的拒绝(rejected)状态。
无论落定为哪种状态都是不可逆的。
只要从待定转换为兑现或拒绝,期约的状态就不再改变。
而且,也不能保证期约必然会脱离待定状态。
因此,组织合理的代码无论期约解决(resolve)还是拒绝(reject),甚至永远处于待定(pending)状态,都应该具有恰当的行为。
重要的是,期约的状态是私有的,不能直接通过JavaScript检测到。
这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。
另外,期约的状态也不能被外部JavaScript代码修改。
这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。
2.解决值、拒绝理由及期约用例
期约主要有两大用途。
首先是抽象地表示一个异步操作。
期约的状态代表期约是否完成。
“待定”表示尚未开始或者正在执行中。“兑现”表示已经成功完成,而“拒绝”则表示没有成功完成。
某些情况下,这个状态机就是期约可以提供的最有用的信息。
知道一段异步代码已经完成,对于其他代码而言已经足够了。
比如,假设期约要向服务器发送一个HTTP请求。请求返回200~299范围内的状态码就足以让期约的状态变为“兑现”。
类似地,如果请求返回的状态码不在200~299这个范围内,那么就会把期约状态切换为“拒绝”。
在另外一些情况下,期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问这个值。
相应地,如果期约被拒绝,程序就会期待期约状态改变时可以拿到拒绝的理由。
比如,假设期约向服务器发送一个HTTP请求并预定会返回一个JSON。如果请求返回范围在200~299的状态码,则足以让期约的状态变为兑现。此时期约内部就可以收到一个JSON字符串。
类似地,如果请求返回的状态码不在200~299这个范围内,那么就会把期约状态切换为拒绝。此时拒绝的理由可能是一个Error对象,包含着HTTP状态码及相关错误消息。
为了支持这两种用例,每个期约只要状态切换为兑现,就会有一个私有的内部值(value)。
类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由(reason)。无论是值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为undefined。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。
3.通过执行函数控制期约状态
由于期约的状态是私有的,所以只能在内部进行操作。
内部操作在期约的执行器函数中完成。
执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。
其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为resolve()和reject()。
调用resolve()会把状态切换为兑现,调用reject()会把状态切换为拒绝。
另外,调用reject()也会抛出错误(后面会讨论这个错误)。
let p1 = new Promise((resolve, reject) => resolve());setTimeout(console.log, 0, p1);let p2 = new Promise((resolve, reject) => reject());setTimeout(console.log, 0, p2);// Uncaught (in promise) undefined// Promise {<fulfilled>: undefined}// Promise {<rejected>: undefined}
在前面的例子中,并没有什么异步操作,因为在初始化期约时,执行器函数已经改变了每个期约的状态。
这里的关键在于,执行器函数是同步执行的。
这是因为执行器函数是期约的初始化程序。
通过下面的例子可以看出上面代码的执行顺序:
new Promise(() => setTimeout(console.log, 0, 'executor'));setTimeout(console.log, 0, 'promise initialized');// executor// promise initialized
添加setTimeout可以推迟切换状态:
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000));// 在console.log打印期约实例的时候,还不会执行超时回调(即resolve())setTimeout(console.log, 0, p);// Promise {<pending>}
无论resolve()和reject()中的哪个被调用,状态转换都不可撤销了。
于是继续修改状态会静默失败,如下所示:
let p = new Promise((resolve, reject) => {resolve();reject(); // 没有效果});setTimeout(console.log, 0, p);// Promise {<fulfilled>: undefined}
为避免期约卡在待定状态,可以添加一个定时退出功能。
比如,可以通过setTimeout,设置一个5秒钟后,无论如何都会拒绝期约的回调:
let p = new Promise((resolve, reject) => {setTimeout(reject, 5000); //5秒之后调用reject()// 执行的逻辑});setTimeout(console.log, 0, p);setTimeout(console.log, 5100, p); // 5秒后再检查状态// Promise {<pending>}// Uncaught (in promise) undefined// Promise {<rejected>: undefined}
因为期约的状态只能改变一次,所以这里的超时拒绝逻辑中,可以放心地设置,让期约处于待定状态的最长时间。
如果执行器中的代码,在超时之前已经解决或拒绝,那么超时回调再尝试拒绝也会静默失败。
4.Promise.resolve()
期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。
通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。
下面两个期约实例实际上是一样的:
let p1 = new Promise((resolve, reject) => resolve());let p2 = Promise.resolve();
这个解决的期约的值,对应着传给Promise.resolve()的第一个参数。
使用这个静态方法,实际上可以把任何值都转换为一个期约:
setTimeout(console.log, 0, Promise.resolve());setTimeout(console.log, 0, Promise.resolve(3));setTimeout(console.log, 0, Promise.resolve(4,5,6)); // 多余的参数会忽略// Promise {<fulfilled>: undefined}// Promise {<fulfilled>: 3}// Promise {<fulfilled>: 4}
对这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似于一个空包装。
因此,Promise.resolve()可以说是一个幂等方法,如下所示:
let p = Promise.resolve(7);setTimeout(console.log, 0, p === Promise.resolve(p));setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p)));// 2true
这个幂等性会保留传入期约的状态:
let p = new Promise(() => {});setTimeout(console.log, 0, p);setTimeout(console.log, 0, Promise.resolve(p));setTimeout(console.log, 0, p === Promise.resolve(p));// Promise {<pending>}// Promise {<pending>}// true
注意,这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。
因此,也可能导致不符合预期的行为:
let p = Promise.resolve(new Error('foo'));setTimeout(console.log, 0, p);// Promise {<fulfilled>: Error: foo
5.Promise.reject()
与Promise.resolve()类似,Promise.reject()会实例化一个拒绝的期约,并抛出一个异步错误(这个错误不能通过try/catch捕获,而只能通过拒绝处理程序捕获)。
下面的两个期约实例实际上是一样的:
let p1 = new Promise((resolve, reject) => reject());let p2 = Promise.reject();
这个拒绝的期约的理由就是,传给Promise.reject()的第一个参数。
这个参数也会传给后续的拒绝处理程序:
let p = Promise.reject(3);setTimeout(console.log, 0, p);p.then(null, (e) => setTimeout(console.log, 0, e));// Promise {<rejected>: 3}// 3
关键在于,Promise.reject()并没有照搬Promise.resolve()的幂等逻辑。
如果给它传一个期约对象,则这个期约,会成为它返回的拒绝期约的理由:
setTimeout(console.log, 0, Promise.reject(Promise.resolve()));// Uncaught (in promise) Promise {<fulfilled>: undefined}// Promise {<rejected>: Promise}
6.同步/异步执行的二元性
Promise的设计很大程度上会导致一种完全不同于JavaScript的计算模式。
下面的例子完美地展示了这一点,其中包含了两种模式下抛出错误的情形:
try {throw new Error('foo');} catch(e) {console.log(e);}try {Promise.reject(new Error('bar'));} catch(e) {console.log(e);}// Error: foo// Uncaught (in promise) Error: bar
第一个try/catch抛出并捕获了错误,第二个try/catch抛出错误却没有捕获到。
乍一看这可能有点违反直觉,因为代码中确实是同步创建了一个拒绝的期约实例,而这个实例也抛出了包含拒绝理由的错误。
这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式捕获错误。
可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
在前面的例子中,拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。
因此,try/catch块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是,使用异步结构——更具体地说,就是期约的方法。
11.2.3 期约的实例方法
期约实例的方法是连接外部同步代码,与内部异步代码之间的桥梁。
这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。
1.实现Thenable接口
在ECMAScript暴露的异步结构中,任何对象都有一个then()方法。
这个方法被认为实现了Thenable接口。
下面的例子展示了实现这一接口的最简单的类:
class MyThenable {then() {}}
ECMAScript的Promise类型实现了Thenable接口。
这个简化的接口跟TypeScript,或其他包中的接口或类型定义不同,它们都设定了Thenable接口更具体的形式。
注:本章后面再介绍异步函数时,还会再谈到Thenable接口的用途和目的。
2.Promise.prototype.then()
Promise.prototype.then()是为期约实例添加处理程序的主要方法。
这个then()方法接收最多两个参数:onResolved处理程序和onRejected处理程序。
这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。
function onResolved(id) {setTimeout(console.log, 0, id, 'resolved');}function onRejected(id) {setTimeout(console.log, 0, id, 'rejected');}let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));p1.then(() => onResolved('p1'),() => onRejected('p1'));p2.then(() => onResolved('p2'),() => onRejected('p2'));// (3秒后)// p1 resolved// p2 rejected
因为期约只能转换为最终状态一次,所以这两个操作一定是互斥的。
如前所述,两个处理程序参数都是可选的。
而且,传给then()的任何非函数类型的参数,都会被静默忽略。
如果想只提供onRejected参数,那就要在onResolved参数的位置上传入undefined。
这样有助于避免在内存中创建多余的对象,对期待函数参数的类型系统也是一个交代。
function onResolved(id) {setTimeout(console.log, 0, id, 'resolved');}function onRejected(id) {setTimeout(console.log, 0, id, 'rejected');}let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));// 非函数处理程序会被静默忽略,不推荐p1.then('ggggg');// 不传onResolved处理程序的规范写法p2.then(null, () => onRejected('p2'));// p2 rejected(3秒后)
Promise.prototype.then()方法返回一个新的期约实例:
let p1 = new Promise(() => {});let p2 = p1.then();setTimeout(console.log, 0, p1);setTimeout(console.log, 0, p2);setTimeout(console.log, 0, p1 === p2);// Promise {<pending>}// Promise {<pending>}// false
这个新期约实例基于onResovled处理程序的返回值构建。
换句话说,该处理程序的返回值会通过Promise.resolve()包装来生成新期约。
如果没有提供这个处理程序,则Promise.resolve()就会包装上一个期约解决之后的值。
如果没有显式的返回语句,则Promise.resolve()会包装默认的返回值undefined。
let p1 = Promise.resolve('foo');// 若调用then()时不传处理程序,则原样向后传let p2 = p1.then();setTimeout(console.log, 0, p2);// 这些都一样let p3 = p1.then(() => undefined);let p4 = p1.then(() => {});let p5 = p1.then(() => Promise.resolve());setTimeout(console.log, 0, p3);setTimeout(console.log, 0, p4);setTimeout(console.log, 0, p5);// Promise {<fulfilled>: "foo"}// Promise {<fulfilled>: undefined}// Promise {<fulfilled>: undefined}// Promise {<fulfilled>: undefined}
如果有显式的返回值,则Promise.resolve()会包装这个值:
let p1 = Promise.resolve('foo');let p6 = p1.then(() => 'bar');let p7 = p1.then(() => Promise.resolve('bar'));setTimeout(console.log, 0, p6);setTimeout(console.log, 0, p7);let p8 = p1.then(() => new Promise(() => {}));let p9 = p1.then(() => Promise.reject());setTimeout(console.log, 0, p8);setTimeout(console.log, 0, p9);// Uncaught (in promise) undefined// Promise {<fulfilled>: "bar"}// Promise {<fulfilled>: "bar"}// Promise {<pending>}// Promise {<rejected>: undefined}
抛出异常会返回拒绝的期约:
let p1 = Promise.resolve('foo');let p10 = p1.then(() => { throw 'baz'; });setTimeout(console.log, 0, p10);// Uncaught (in promise) baz// Promise {<rejected>: "baz"}
注意,返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的期约中:
let p1 = Promise.resolve('foo');let p11 = p1.then(() => Error('qux'));setTimeout(console.log, 0, p11);// Promise {<fulfilled>: Error: qux
onRejected处理程序也与之类似:
onRejected处理程序返回的值,也会被Promise.resolve()包装。
细想:
onRejected处理程序的任务不就是捕获异步错误吗?
因此,拒绝处理程序在捕获错误后不抛出异常,是符合期约的行为,应该返回一个解决期约。
下面的代码片段展示了,用Promise.reject()替代之前例子中的Promise.resolve()之后的结果:
let p1 = Promise.reject('foo');// 调用then()时不传处理程序则原样向后传let p2 = p1.then();setTimeout(console.log, 0, p2); // Uncaught (in promise) foo// Promise {<rejected>: "foo"}let p3 = p1.then(null, () => undefined);let p4 = p1.then(null, () => {});let p5 = p1.then(null, () => Promise.resolve());setTimeout(console.log, 0, p3); // Promise {<fulfilled>: undefined}setTimeout(console.log, 0, p4); // Promise {<fulfilled>: undefined}setTimeout(console.log, 0, p5); // Promise {<fulfilled>: undefined}// 这些都一样let p6 = p1.then(null, () => 'bar');let p7 = p1.then(null, () => Promise.resolve('bar'));setTimeout(console.log, 0, p6); // Promise {<fulfilled>: "bar"}setTimeout(console.log, 0, p7); // Promise {<fulfilled>: "bar"}// Promise.resolve()保留返回的期约let p8 = p1.then(null, () => new Promise(() => {}));let p9 = p1.then(null, () => Promise.reject());setTimeout(console.log, 0, p8); // Promise {<pending>}setTimeout(console.log, 0, p9); // Promise {<rejected>: undefined}let p10 = p1.then(null, () => { throw 'baz'; });setTimeout(console.log, 0, p10); // Uncaught (in promise) baz// Promise {<rejected>: "baz"}let p11 = p1.then(() => Error('qux'));setTimeout(console.log, 0, p11); // Uncaught (in promise) foo// Promise {<rejected>: "foo"}
3.Promise.prototype.catch()
Promise.prototype.catch()方法,用于给期约添加拒绝处理程序。
只接收一个参数:onRejected处理程序。
这个方法就是一个语法糖,调用它就相当于调用Promise.prototype. then(null, onRejected)。
下面的代码展示了这两种同样的情况:
let p = Promise.reject();let onRejected = function(e) {setTimeout(console.log, 0, 'rejected');};// 这两种添加拒绝处理程序的方法是一样的p.then(null, onRejected);p.catch(onRejected);// 2rejected
Promise.prototype.catch()返回一个新的期约实例:
let p1 = new Promise(() => {});let p2 = p1.catch();setTimeout(console.log, 0, p1); // Promise {<pending>}setTimeout(console.log, 0, p2); // Promise {<pending>}setTimeout(console.log, 0, p1 === p2); // false
在返回新期约实例方面,Promise.prototype.catch()的行为,与Promise.prototype.then()的onRejected处理程序,是一样的。
4.Promise.prototype.finally()
Promise.prototype.finally()方法,用于给期约添加onFinally处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。
这个方法可以避免onResolved和onRejected处理程序中出现冗余代码。
但onFinally处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。
let p1 = Promise.resolve();let p2 = Promise.reject();let onFinally = function() {setTimeout(console.log, 0, 'Finally!')}p1.finally(onFinally);p2.finally(onFinally);// Uncaught (in promise) undefined// 2Finally!
Promise.prototype.finally()方法返回一个新的期约实例:
let p1 = new Promise(() => {});let p2 = p1.finally();setTimeout(console.log, 0, p1); // Promise {<pending>}setTimeout(console.log, 0, p2); // Promise {<pending>}
这个新期约实例不同于then()或catch()方式返回的实例。
因为onFinally被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。
let p1 = Promise.resolve('foo');let p2 = p1.finally();let p3 = p1.finally(() => undefined);let p4 = p1.finally(() => {});let p5 = p1.finally(Promise.resolve());let p6 = p1.finally(() => 'bar');let p7 = p1.finally(() => Promise.resolve('bar'));let p8 = p1.finally(() => Error('qux'));setTimeout(console.log, 0, p1);setTimeout(console.log, 0, p2);setTimeout(console.log, 0, p3);setTimeout(console.log, 0, p4);setTimeout(console.log, 0, p5);setTimeout(console.log, 0, p6);setTimeout(console.log, 0, p7);setTimeout(console.log, 0, p8);// Promise {<fulfilled>: "foo"} (8个一样的)
如果返回的是一个待定的期约,或者onFinally处理程序抛出了错误(显式抛出或返回了一个拒绝期约),则会返回相应的期约(待定或拒绝),
如下所示:
let p1 = Promise.resolve('foo');let p9 = p1.finally(() => new Promise(() => {}));let p10 = p1.finally(() => Promise.reject());setTimeout(console.log, 0, p9); // Promise {<pending>}// Uncaught (in promise) undefinedsetTimeout(console.log, 0, p10); // Promise {<rejected>: undefined}let p11 = p1.finally(() => { throw 'baz'; });// Uncaught (in promise) bazsetTimeout(console.log, 0, p11); // Promise {<rejected>: "baz"}
返回待定期约的情形并不常见,这是因为只要期约一解决,新期约仍然会原样后传初始的期约:
let p1 = Promise.resolve('foo');// 忽略解决的值let p2 = p1.finally(() => new Promise((resolve, reject) => setTimeout(() => resolve('bar'), 100)));setTimeout(console.log, 0, p2); // Promise {<pending>}setTimeout(() => setTimeout(console.log, 0, p2), 200);// Promise {<fulfilled>: "foo"}
5.非重入期约方法
当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。
跟在添加这个处理程序的代码之后的同步代码,一定会在处理程序之前先执行。
即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。
这个特性由JavaScript运行时保证,被称为“非重入”(non-reentrancy)特性。
下面的例子演示了这个特性:
// 创建解决的期约let p = Promise.resolve();// 添加解决处理程序// 直觉上,这个处理程序会等期约一解决就执行p.then(() => console.log(`onResolved 处理程序`));// 同步输出,证明then(已经返回)console.log(`then()已返回`);// 实际的输出:// then()已返回// onResolved 处理程序
在一个解决期约上调用then(),会把onResolved处理程序推进消息队列。
但这个处理程序,在当前线程上的同步代码执行完成前,不会执行。
因此,跟在then()后面的同步代码一定先于处理程序执行。
先添加处理程序后解决期约也是一样的。如果添加处理程序后,同步代码才改变期约状态,那么处理程序仍然会基于该状态变化表现出非重入特性。
下面的例子展示了:即使先添加了onResolved处理程序,再同步调用resolve(),处理程序也不会进入同步线程执行:
let synchronousResolve;let p = new Promise((resolve) => {synchronousResolve = function() {console.log('1: 引用resolve()');resolve();console.log('2: 返回resolve()')};});p.then(() => console.log('4: then() 处理程序执行器'));synchronousResolve();console.log('3: 返回synchronousResolve()');// 1: 引用resolve()// 2: 返回resolve()// 3: 返回synchronousResolve()// 4: then() 处理程序执行器
在这个例子中,即使期约状态变化发生在添加处理程序之后,处理程序也会等到运行的消息队列让它出列时才会执行。
非重入适用于onResolved/onRejected处理程序、catch()处理程序和finally()处理程序。
下面的例子演示了这些处理程序都只能异步执行:
let p1 = Promise.resolve();p1.then(() => console.log(`p1.then() onResolved`));console.log(`返回p1.then()`);let p2 = Promise.reject();p2.then(null, () => console.log(`p2.then() onRejected`));console.log(`返回p2.then()`);p2.then(null, () => console.log(`p2.then() onRejected`));let p3 = Promise.reject();p3.then(null, () => console.log(`p3.catch() onResolved`));console.log(`返回p3.catch()`);let p4 = Promise.resolve();p4.then(null, () => console.log(`p4.finally() onFinally`));console.log(`返回p4.finally()`);// 返回p1.then()// 返回p2.then()// 返回p3.catch()// 返回p4.finally()// p1.then() onResolved// p2.then() onRejected// p2.then() onRejected// p3.catch() onResolved
6.邻近处理程序的执行顺序
如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。
无论是then()、catch()还是finally()添加的处理程序都是如此。
let p1 = Promise.resolve();let p2 = Promise.reject();p1.then(() => setTimeout(console.log, 0, 1));p1.then(() => setTimeout(console.log, 0, 2));p2.then(null, () => setTimeout(console.log, 0, 3));p2.then(null, () => setTimeout(console.log, 0, 4));p2.catch(() => setTimeout(console.log, 0, 5));p2.catch(() => setTimeout(console.log, 0, 6));p1.finally(() => setTimeout(console.log, 0, 7));p1.finally(() => setTimeout(console.log, 0, 8));// 1// 2// 3// 4// 5// 6// 7// 8
7.传递解决值和拒绝理由
到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。
拿到返回值后,就可以进一步对这个值进行操作。
比如,第一次网络请求返回的JSON是发送第二次请求必需的数据,那么第一次请求返回的值就应该传给onResolved处理程序继续处理。
当然,失败的网络请求也应该把HTTP状态码传给onRejected处理程序。
在执行函数中,解决的值和拒绝的理由是分别作为resolve()和reject()的第一个参数往后传的。
然后,这些值又会传给它们各自的处理程序,作为onResolved或onRejected处理程序的唯一参数。
下面的例子展示了上述传递过程:
let p1 = new Promise((resolve, reject) => resolve('foo'));p1.then((value) => console.log(value));let p2 = new Promise((resolve, reject) => reject('bar'));p2.catch((reason) => console.log(reason));// foo// bar
Promise.resolve()和Promise.reject()在被调用时就会接收解决值和拒绝理由。
同样地,它们返回的期约也会像执行器一样把这些值传给onResolved或onRejected处理程序:
let p1 = Promise.resolve('foo');p1.then((value) => console.log(value));let p2 = Promise.reject('bar');p2.catch((reason) => console.log(reason));// foo// bar
8.拒绝期约与拒绝错误处理
拒绝期约类似于throw()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。
在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。
因此以下这些期约都会以一个错误对象为由被拒绝:
let p1 = new Promise((resolve, reject) => reject(Error('foo')));let p2 = new Promise((resolve, reject) => { throw Error('foo'); });let p3 = Promise.resolve().then(() => { throw Error('foo'); });let p4 = Promise.reject(Error('foo'));setTimeout(console.log, 0, p1);setTimeout(console.log, 0, p2);setTimeout(console.log, 0, p3);setTimeout(console.log, 0, p4);// Promise {<rejected>: Error: foo (4个一样的)// 也会抛出四个未捕获错误: Uncaught (in promise) Error: foo
期约可以以任何理由拒绝,包括undefined,但最好统一使用错误对象。
因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的。
例如,前面例子中抛出的4个错误的栈追踪信息如下:
Uncaught (in promise) Error: fooat test.html:5at new Promise (<anonymous>)at test.html:5Uncaught (in promise) Error: fooat test.html:6at new Promise (<anonymous>)at test.html:6Uncaught (in promise) Error: fooat test.html:8Uncaught (in promise) Error: fooat test.html:7
所有错误都是异步抛出且未处理的,通过错误对象捕获的栈追踪信息展示了错误发生的路径。
注意错误的顺序:Promise.resolve().then()的错误最后才出现,这是因为它需要在运行时消息队列中添加处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。
这个例子同样揭示了异步错误有意思的副作用。
正常情况下,在通过throw()关键字抛出错误时,JavaScript运行时的错误处理机制会停止执行抛出错误之后的任何指令:
throw Error('foo');console.log('bar'); // 这一行不会执行// Uncaught Error: foo
但是,在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令:
Promise.reject(Error('foo'));console.log('bar');// bar// Uncaught (in promise) Error: foo
如本章前面的Promise.reject()示例所示,异步错误只能通过异步的onRejected处理程序捕获:
// 正确Promise.reject(Error('foo')).catch((e) => {});// 不正确try {Promise.reject(Error('foo'));} catch(e) {}
这不包括捕获执行函数中的错误,在解决或拒绝期约之前,仍然可以使用try/catch在执行函数中捕获错误:
let p = new Promise((resolve, reject) => {try {throw Error('foo');} catch(e) {}resolve('bar')});setTimeout(console.log, 0, p);// Promise {<fulfilled>: "bar"}
then()和catch()的onRejected处理程序在语义上相当于try/catch。
出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。
为此,onRejected处理程序的任务应该是,在捕获异步错误之后返回一个解决的期约。
下面的例子中对比了同步错误处理与异步错误处理:
console.log('开始同步执行');try {throw Error('foo');} catch(e) {console.log('caught error', e);}console.log('继续同步执行');new Promise((resolve, reject) => {console.log('开始异步执行');reject(Error('bar'));}).catch((e) => {console.log('caught error', e);}).then(() => {console.log('继续异步执行');});// 开始同步执行// caught error Error: foo// 继续同步执行// 开始异步执行// caught error Error: bar// 继续异步执行
11.2.4 期约连锁与期约合成
多个期约组合在一起可以构成强大的代码逻辑。
这种组合可以通过两种方式实现:期约连锁与期约合成。
前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。
1.期约连锁
把期约逐个地串联起来是一种非常有用的编程模式。
这样做是因为:每个期约实例的方法(then()、catch()和finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。
比如:
let p = new Promise((resolve, reject) => {console.log('first');resolve();});p.then(() => console.log('second')).then(() => console.log('third')).then(() => console.log('fourth'));// first// second// third// fourth
这个实现最终执行了一连串同步任务。
正因为如此,这种方式执行的任务没有那么有用,毕竟分别使用4个同步函数也可以做到:
(() => console.log('first'))();(() => console.log('second'))();(() => console.log('third'))();(() => console.log('fourth'))();// first// second// third// fourth
要真正执行异步任务,可以改写前面的例子,让每个执行器都返回一个期约实例。
这样就可以让每个后续期约都等待之前的期约,也就是串行化异步任务。
比如,可以像下面这样让每个期约在一定时间后解决:
let p1 = new Promise((resolve, reject) => {console.log('p1 执行者');setTimeout(resolve, 1000);});p1.then(() => new Promise((resolve, reject) => {console.log('p2 执行者');setTimeout(resolve, 1000);})).then(() => new Promise((resolve, reject) => {console.log('p3 执行者');setTimeout(resolve, 1000);})).then(() => new Promise((resolve, reject) => {console.log('p4 执行者');setTimeout(resolve, 1000);}));// p1 执行者(1秒后)// p2 执行者(2秒后)// p3 执行者(3秒后)// p4 执行者(4秒后)
把生成期约的代码提取到一个工厂函数中,就可以写成这样:
function delayedResolve(str) {return new Promise((resolve, reject) => {console.log(str);setTimeout(resolve, 1000);});}delayedResolve('p1 执行者').then(() => delayedResolve('p2 执行者')).then(() => delayedResolve('p3 执行者')).then(() => delayedResolve('p4 执行者'));// p1 执行者(1秒后)// p2 执行者(2秒后)// p3 执行者(3秒后)// p4 执行者(4秒后)
每个后续的处理程序都会等待前一个期约解决,然后实例化一个新期约并返回它。
这种结构可以简洁地将异步任务串行化,解决之前依赖回调的难题。
假如这种情况下不使用期约,那么前面的代码可能就要这样写了:
function delayedResolve(str, callback = null) {setTimeout(() => {console.log(str);callback && callback();}, 1000)}delayedResolve('p1 callback', () => {delayedResolve('p2 callback', () => {delayedResolve('p3 callback', () => {delayedResolve('p4 callback');})})})// p1 callback(1秒后)// p2 callback(2秒后)// p3 callback(3秒后)// p4 callback(4秒后)
这正是期约所要解决的回调地狱问题
因为then()、catch()和finally()都返回期约,所以串联这些方法也很直观。
下面的例子同时使用这3个实例方法:
let p = new Promise((resolve, reject) => {console.log('initial promise rejects');reject();});p.catch(() => console.log('reject handler')).then(() => console.log('resolve handler')).finally(() => console.log('finally handler')// initial promise rejects// reject handler// resolve handler// finally handler
2.期约图
因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。
这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。
因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。
下面的例子展示了一种期约有向图,也就是二叉树:
let A = new Promise((resolve, reject) => {console.log('A');resolve();});let B = A.then(() => console.log('B'));let C = A.then(() => console.log('C'));B.then(() => console.log('D'));B.then(() => console.log('E'));C.then(() => console.log('F'));C.then(() => console.log('G'));// A// B// C// D// E// F// G
注意,日志的输出语句是对二叉树的层序遍历。
如前所述,期约的处理程序是按照它们添加的顺序执行的。
由于期约的处理程序是先添加到消息队列,然后才逐个执行,因此构成了层序遍历。
树只是期约图的一种形式。
考虑到根节点不一定唯一,且多个期约也可以组合成一个期约(通过下一节介绍的Promise.all()和Promise.race()),所以有向非循环图是体现期约连锁可能性的最准确表达。
3.Promise.all()和Promise.race()
Promise类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()和Promise.race()。
而合成后期约的行为取决于内部期约的行为。
● Promise.all()
Promise.all()静态方法创建的期约,会在一组期约全部解决之后再解决。
这个静态方法接收一个可迭代对象,返回一个新期约:
let p1 = Promise.all([Promise.resolve(),Promise.resolve()]);// 可迭代对象中的元素会通过Promise.resolve()转换为期约let p2 = Promise.all([3,4]);// 空的可迭代对象等价于Promise.resolve()let p3 = Promise.all([]);// 无效的语法let p4 = Promise.all();// Uncaught (in promise) TypeError: undefined is not iterable// (cannot read property Symbol(Symbol.iterator))at Function.all (<anonymous>)
合成的期约只会在每个包含的期约都解决之后才解决:
let p = Promise.all([Promise.resolve(),new Promise((resolve, reject) => setTimeout(resolve, 1000))]);setTimeout(console.log, 0, p);p.then(() => setTimeout(console.log, 0, 'all() resolved!'));// Promise {<pending>}// all() resolved!
如果至少有一个包含的期约待定,则合成的期约也会待定。
如果有一个包含的期约拒绝,则合成的期约也会拒绝:
// 永远待定let p1 = Promise.all([new Promise(() => {})]);setTimeout(console.log, 0, p1); // Promise {<pending>}// 一次拒绝会导致最终期约拒绝let p2 = Promise.all([Promise.resolve(),Promise.reject(),Promise.resolve()]);setTimeout(console.log, 0, p2);// Uncaught (in promise) undefined// Promise {<rejected>: undefined}
如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,按照迭代器顺序:
let p = Promise.all([Promise.resolve(3),Promise.resolve(),Promise.resolve(4)]);p.then((values) => setTimeout(console.log, 0, values));// [3, undefined, 4]
如果有期约拒绝,则第一个拒绝的期约,会将自己的理由作为合成期约的拒绝理由。
之后再拒绝的期约,不会影响最终期约的拒绝理由。
不过,这并不影响所有包含期约正常的拒绝操作。合成的期约会静默处理所有包含期约的拒绝操作
如下所示:
// 虽然只有第一个期约的拒绝理由会进入拒绝处理程序,// 第二个期约的拒绝也会被静默处理,不会有错误跑掉let p = Promise.all([Promise.reject(3),new Promise((resolve, reject) => setTimeout(reject, 1000))]);p.catch((reason) => setTimeout(console.log, 0, reason));// 3// 没有未处理的错误
●Promise.race()
Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。
这个方法接收一个可迭代对象,返回一个新期约:
let p1 = Promise.race([Promise.resolve(),Promise.resolve()]);// 可迭代对象中的元素会通过Promise.resolve()转换为期约let p2 = Promise.race([3,4]);// 空的可迭代对象等价于new Promise(() => {})let p3 = Promise.race([]);// 无效的语法let p4 = Promise.race();Uncaught (in promise) TypeError: undefined is not iterable// (cannot read property Symbol(Symbol.iterator)) at Function.race (<anonymous>)
Promise.race()不会对解决或拒绝的期约区别对待。
无论是解决还是拒绝,只要是第一个落定的期约,Promise.race()就会包装其解决值或拒绝理由并返回新期约:
// 解决先发生,超时后的拒绝被忽略let p1 = Promise.race([Promise.resolve(3),new Promise((resolve, reject) => setTimeout(reject, 1000))]);setTimeout(console.log, 0, p1); // Promise {<fulfilled>: 3}// 拒绝先发生,超时后的解决被忽略let p2 = Promise.race([Promise.reject(4),new Promise((resolve, reject) => setTimeout(resolve, 1000))]);setTimeout(console.log, 0, p2); // Uncaught (in promise) 4// Promise {<rejected>: 4}// 迭代顺序决定了落定顺序let p3 = Promise.race([Promise.resolve(5),Promise.resolve(6),Promise.resolve(7)]);setTimeout(console.log, 0, p3); // Promise {<fulfilled>: 5}
如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。
之后再拒绝的期约不会影响最终期约的拒绝理由。
不过,这并不影响所有包含期约正常的拒绝操作。
与Promise.all()类似,合成的期约会静默处理所有包含期约的拒绝操作,
如下所示:
// 虽然只有第一个期约的拒绝理由会进入拒绝处理程序,// 第二个期约的拒绝也会被静默处理,不会有错误跑掉let p = Promise.race([Promise.reject(3),new Promise((resolve, reject) => setTimeout(reject, 1000))]);p.catch((reason) => setTimeout(console.log, 0, reason));// 3// 没有未处理的错误
4.串行期约合成
期约的另一个主要特性:异步产生值并将其传给处理程序。
基于后续期约使用之前期约的返回值来串联期约,是期约的基本功能。
这很像函数合成,即将多个函数合成为一个函数,比如:
function addTwo(x) { return x + 2 }function addThree(x) { return x + 3 }function addFive(x) { return x + 5 }function addTen(x) {return addFive(addTwo(addThree(x)));}console.log(addTen(7)); // 17
在这个例子中,有3个函数基于一个值合成为一个函数。
类似地,期约也可以像这样合成起来,渐进地消费一个值,并返回一个结果:
function addTwo(x) { return x + 2 }function addThree(x) { return x + 3 }function addFive(x) { return x + 5 }function addTen(x) {return Promise.resolve(x).then(addTwo).then(addThree).then(addFive);}addTen(8).then(console.log); // 18
使用Array.prototype.reduce()可以写成更简洁的形式:
function addTwo(x) { return x + 2 }function addThree(x) { return x + 3 }function addFive(x) { return x + 5 }function addTen(x) {return [addTwo, addThree, addFive].reduce((promise, fn) => promise.then(fn), Promise.resolve(x));}addTen(8).then(console.log); // 18
这种模式可以提炼出一个通用函数,可以把任意多个函数作为处理程序合成一个连续传值的期约连锁。这个通用的合成函数可以这样实现:
function addTwo(x) { return x + 2 }function addThree(x) { return x + 3 }function addFive(x) { return x + 5 }function compose(...fns) {return (x) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x))}let addTen = compose(addTwo, addThree, addFive);addTen(8).then(console.log); // 18
注意 本章后面的11.3节在讨论异步函数时还会涉及这个概念。
11.2.5 期约扩展
ES6期约不足之处:
比如,很多第三方期约库实现中具备,而ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。
1.期约取消
我们会遇到期约正在处理过程中,程序却不再需要其结果的情形。这时候如果能够取消期约就好了。
某些第三方库,比如Bluebird,就提供了这个特性。
实际上,TC39委员会也曾准备增加这个特性,但相关提案最终被撤回了。结果,ES6期约被认为是“激进的”:只要期约的逻辑开始执行,就没有办法阻止它执行到完成。
实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。
这可以用到Kevin Smith提到的“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;
同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态。
下面是CancelToken类的一个基本实例:
class CancelToken {constructor(cancelFn) {this.promise = new Promise((resolve, reject) => {cancelFn(resolve);});}}
这个类包装了一个期约,把解决方法暴露给了cancelFn参数。这样,外部代码就可以向构造函数中传入一个函数,从而控制什么情况下可以取消期约。
这里期约是令牌类的公共成员,因此可以给它添加处理程序以取消期约。
这个类大概可以这样使用:
<body><button id="start">开始</button><button id="cancel">取消</button><script>class CancelToken {constructor(cancelFn) {this.promise = new Promise((resolve, reject) => {cancelFn(() => {setTimeout(console.log, 0, '延迟取消');resolve();});});}}const startBtn = document.querySelector('#start');const cancelBtn = document.querySelector('#cancel');function cancellableDelayedResolve(delay) {setTimeout(console.log, 0, '设置延迟');return new Promise((resolve, reject) => {const id = setTimeout(() => {setTimeout(console.log, 0, '延迟resolved');resolve();}, delay)const cancelToken = new CancelToken((cancelCallback) =>cancelBtn.addEventListener('click', cancelCallback));cancelToken.promise.then(() => clearTimeout(id));});}startBtn.addEventListener('click', () => cancellableDelayedResolve(1000));
每次单击“Start”按钮都会开始计时,并实例化一个新的CancelToken的实例。
此时,“Cancel”按钮一旦被点击,就会触发令牌实例中的期约解决。而解决之后,单击“Start”按钮设置的超时也会被取消。
2.期约进度通知
执行中的期约可能会有不少离散的“阶段”,在最终解决之前必须依次经过。
某些情况下,监控期约的执行进度会很有用。
ECMAScript 6期约并不支持进度追踪,但是可以通过扩展来实现。
一种实现方式是扩展Promise类,为它添加notify()方法,
如下所示:
class TrackablePromise extends Promise {constructor(exector) {const notifyHandlers = [];super((resolve, reject) => {return exector(resolve, reject, (status) => {notifyHandlers.map((handler) => handler(status));});});this.notifyHandlers = notifyHandlers;}notify(notifyHandler) {this.notifyHandlers.push(notifyHandler);return this;}}
这样,TrackablePromise就可以在执行函数中使用notify()函数了。
可以像下面这样使用这个函数来实例化一个期约:
let p = new TrackablePromise((resolve, reject, notify) => {function countdown(x) {if (x > 0) {notify(`${20*x}%remaining`);setTimeout(() => countdown(x-1), 1000);} else {resolve();}}countdown(5);});
这个期约会连续5次递归地设置1000毫秒的超时。
每个超时回调都会调用notify()并传入状态值。
假设通知处理程序简单地这样写:
let p = new TrackablePromise((resolve, reject, notify) => {function countdown(x) {if (x > 0) {notify(`${20*x}%剩余`);setTimeout(() => countdown(x-1), 1000);} else {resolve();}}countdown(5);});p.notify((x) => setTimeout(console.log, 0, '进程:', x));p.then((x) => setTimeout(console.log, 0, '完成'));// (约1秒后)进程: 80%剩余// (约2秒后)进程: 60%剩余// (约3秒后)进程: 40%剩余// (约4秒后)进程: 20%剩余// (约5秒后)完成
notify()函数会返回期约,所以可以连缀调用,连续添加处理程序。
多个处理程序会针对收到的每条消息分别执行一遍,
如下所示:
...p.notify((x) => setTimeout(console.log, 0, '语文:', x)).notify((x) => setTimeout(console.log, 0, '数学:', x));p.then((x) => setTimeout(console.log, 0, '完成'));// (约1秒后)语文: 80%剩余// (约1秒后)数学: 80%剩余// (约2秒后)语文: 60%剩余// (约2秒后)数学: 60%剩余// (约3秒后)语文: 40%剩余// (约3秒后)数学: 40%剩余// (约4秒后)语文: 20%剩余// (约4秒后)数学: 20%剩余// (约5秒后)完成
总体来看,这还是一个比较粗糙的实现,但应该可以演示出如何使用通知报告进度了。
注意 ES6不支持取消期约和进度通知。主要原因:这样会导致期约连锁和期约合成过度复杂化。
比如在一个期约连锁中,如果某个被其他期约依赖的期约被取消了或者发出了通知,那么接下来应该发生什么完全说不清楚。毕竟,如果取消了Promise.all()中的一个期约,或者期约连锁中前面的期约发送了一个通知,那么接下来应该怎么办才比较合理呢?
