期约是对尚不存在的一个替身。期约(promise)这个名字最早是由 Daniel Friedman和 David Wise在他们于 1976 年发表的论文“The Impact of Applicative Programming on Multiprocessing”中提出来的。但直到十几年以后,Barbara Liskov 和 Liuba Shrira 在 1988 年发表了论文“Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems”,这个概念才真正确立下来。同一时期的计算机科学家还使用了“终局”(eventual)、“期许”(future)、“延迟”(delay)和“迟付”(deferred)等术语指代同样的概念。所有这些概念描述的都是一种异步程序执行的机制。
ES6新增的引用类型Promise
,可以通过new操作符 来实例化,创建新期约时需要传入执行器executor
函数作为参数,下面给出一个空函数对象来应付一下解释器:
let p = new Promise(() => {});
setTimeout(console.log(), 0, p); // Promise
之所以说是应付解释器,这是因为不提供执行器函数,就会抛出SyntaxError
。
把一个期约实例传给了console.log()
时,控制台输出(可能会因为浏览器不同而存在差异)表明了该实例处于待定pending状态,如前述,期约Promise是一个有状态的对象,可能处于以下三种状态之一:
待定(pending)就是期约Promise的最初状态。在待定状态下,promise可以落定(settled),代表成功的兑现状态,或者就是代表失败的拒绝状态;
注意:无论是那种都是不可逆的。
只要从待定转换为兑现或者是拒绝,promise的状态就不会再次改变,并且,不能保证promise永远处于脱离待定状态。因此,组织合理的代码无论promise解决了resolved还是说拒绝了rejected,亦或是永远处于待定pending状态,都应该具有恰当的行为。
重要的是,期约的状态是私有的,不能直接通过 JavaScript 检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。另外,
期约的状态也不能被外部 JavaScript 代码修改
。这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。
promise首先具有两大用途:首先是抽象的的表示一个异步操作。promise的状态代表promise是否完成。pending待定状态 表示尚未开始或者进行;兑现 则表示已经完成成功,而rejected拒绝 则表示没有成功完成。
某些情况下,该状态机就是promise最有价值的的信息,若知道一段异步代码已经完成实现,对于其他模块代码而言已经足够;例如,假定promise要向服务器发送一个HTTP请求,返回200~299范围内的状态码足够让promise状态变为兑现,类似,若请求不在该范围之内,则可以说明状态已经处于rejected。
在另外一些情况下,期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问
这个值。相应地,如果期约被拒绝,程序就会期待期约状态改变时可以拿到拒绝的理由。比如,假设期约向服务器发送一个 HTTP 请求并预定会返回一个 JSON。如果请求返回范围在200 ~ 299的状态码,则足以让期约的状态变为兑现。此时期约内部就可以收到一个 JSON 字符串。类似地,如果请求返回的状态码不在 200~299 这个范围内,那么就会把期约状态切换为拒绝。此时拒绝的理由可能是一个 Error对象,包含着 HTTP 状态码及相关错误消息。
为了支持这两种用例,每一个promise只要状态切换为兑现,就会有一个私有的内部value数值,类似的,每一个promise只要状态切换为拒绝,也会存在一个对应的私有内部value;无论是数值还是理由,都会包含原始数值或者是对象的不可修改的引用。二者都是可选的,而且默认数值都是undefined
;在promise达到某一个落定的状态时执行的异步代码始终会接受这个数值或者理由。
由于期约的状态是私有的,所以只能够在内部进行操作,内部操作都只能在promise的执行器函数中完成,执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个参数通常命名为resolved和rejected,调用resolved会转换为兑现状态,调用rejected会转换为拒绝态,另外调用rejected也会抛出错误。
let p1 = new Promise((resolve, reject) => resolve());
setTimeout(console.log, 0, p1); // Promise
let p2 = new Promise((resolve, reject) => reject());
setTimeout(console.log, 0, p2); // Promise
// Uncaught error (in promise)
在前面的例子中,并没有什么异步操作,这是由于初始化promise时,执行器函数已经改变了每一个promise的状态,这里的关键在于,执行器函数时同步执行,这是由于执行器函数是promise的初始化程序。通过下面的例子可以看出上面的代码执行顺序:
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
无论是resolved还是rejected 哪一个被调用了,状态转换都不可以被撤销,于是继续修改状态为默认状态都会失败,如下图所示:
let p = new Promise((resolve, reject) => {
resolve();
reject(); // 没有效果
});
setTimeout(console.log, 0, p); // Promise
为避免期约卡在待定状态,可以添加一个定时退出功能。比如,可以通过 setTimeout 设置一个10 秒钟后无论如何都会拒绝期约的回调:
setTimeout(reject, 10000); // 10 秒后调用 reject()
// 执行函数的逻辑
});
setTimeout(console.log, 0, p); // Promise
setTimeout(console.log, 11000, p); // 11 秒后再检查状态
// (After 10 seconds) Uncaught error
// (After 11 seconds) Promise
由于promise的状态只可以改变一次,所以这里的超时拒绝逻辑可以放心设置让promise处于待定状态的最长时间,若执行器中的代码可以再超时之前已经解决了或者拒绝了,那么超时回调就会再次尝试拒绝,但依旧会静默失败。
promise并不是一开始就必须处于待定的状态,然后通过执行器函数才能转换为落定状态,通过调用Promise.reslove()方法,可以实例化一个解决的promise,下面的两个promise实际上是一样的。
let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();
这个解决的期约的值对应着传给 Promise.resolve()的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个promise:
setTimeout(console.log, 0, Promise.resolve());
// Promise : undefined
setTimeout(console.log, 0, Promise.resolve(3));
// Promise : 3
// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4, 5, 6));
// Promise : 4
对于这个静态方法而言,若传入的参数本身就是一个期约,那么其行为就是类似于一个空包装,因此Promise.resolve可以说是一个幂等方法,类似:
let p = Promise.resolve(7);
setTimeout(console.log, 0, p === Promise.resolve(p));
// true
setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p)));
// true
这个幂等性会一致保留传入promise的状态:
let p = new Promise(() => {});
setTimeout(console.log, 0, p); // Promise
setTimeout(console.log, 0, Promise.resolve(p)); // Promise
setTimeout(console.log, 0, p === Promise.resolve(p)); // true
注意,这个静态方法可以包装任何非promise,包括错误对象,并且将其转换为解决的promise;因此也可能会导致不符合预期的行为:
let p = Promise.resolve(new Error('foo'));
setTimeout(console.log, 0, p);
// Promise : Error: foo
和promise.resolve方法类似,Promise.reject会实例化一个拒绝的promise,并且给出一个异步错误,这个错误不能够通过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); // Promise : 3
p.then(null, (e) => setTimeout(console.log, 0, e)); // 3
关键在于,Promise.reject并不会完全照搬Promise.resolve的幂等逻辑,若让其传一个Promise对象,则其会成为返回的拒绝promise理由:
setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise : Promise
promise的设计很大程度上会导致一种完全不同于JavaScript的计算模式,如下例子完美的展示了这一点,其中包含了两种模式下抛出错误的情况:
throw new Error('foo');
} catch(e) {
console.log(e); // Error: foo
}
try {
Promise.reject(new Error('bar'));
} catch(e) {
console.log(e);
}
// Uncaught (in promise) Error: bar
第一个try/catch
方法抛出了错误并且捕获,第二个抛出了错误但是没有捕获,乍一看这可能
有点违反直觉,因为代码中确实是同步创建了一个拒绝的期约实例,而这个实例也抛出了包含拒绝理由的错误。这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
在上面的例子中,拒绝promise的错误并没有执行同步代码的线程中,而是通过浏览器异步消息队列来处理的;所以try/catch
块不能捕获这个错误。代码一旦开始以异步模式开始执行,则唯一与之交互的方式就是使用异步结构——也就是promise的方法。
promise的实例方法就是
连接外部的同步代码块和内部的异步代码之间的桥梁
,这些方法可以访问异步操作返回的data,处理promise的成功和失败的结果,连续对promise求值,或者添加add只有promise进入终止状态才会执行的代码。
在ES暴露的异步结构中,任何对象都有一个then方法
,这个简化的接口和typescript或者其他包中的接口和类型定义不一样,都设定了thenable接口更加具体的形式。
class MyThenable {
then() {}
}
ES的Promise实现了Thenable
接口。这个简化的接口是跟TS或者其他包中国的接口或者类型定义不同,都设定了Thenable
接口更具体的形式。
该方法是Promise实例添加处理程序的主要方法。这个then()
方法接受最多两个参数:onResolve处理程序和onRejected拒绝处理
;这两个参数都是可选的,若提供,则会分别在Promise
进入兑现 和拒绝 状态时候执行:
function() {
setTimeout(console.log, 0, id, 'resolved')
}
function() {
setTimeout(console.log, 0, id, 'rejected')
}
let p1 = new Promise((resolved, reject) => setTimeout(resolved, 3000));
let p2 = new Promise((resolved, reject) => setTimeout(reject, 3000));
p1.then(() => onResolved('p1'),
() => onRejected('p1'));
p2.then(() => onResolved('p2'),
() => onRejected('p2'));
//(3 秒后)
// p1 resolved
// p2 rejected
由于promise只能转换为最终状态一次,所以该方法的两个操作一定是互斥。
两个处理程序参数都是可选的。而且,传给 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('gobbeltygook');
// 不传 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); // Promise
setTimeout(console.log, 0, p2); // Promise
setTimeout(console.log, 0, p1 === p2); // false
这个新的Promise实例基于onResolve
处理程序的返回值构建,换句话说,该处理程序的返回值会通过Promise.resolve()包
装来生成新期约。如果没有提供这个处理程序,则 Promise.resolve()就会包装上一个期约解决之后的值。如果没有显式的返回语句,则 Promise.resolve()会包装默认的返回值 undefined。
这个方法用于给promise添加拒绝处理程序
,只能够接受一个参数。onRejected
处理程序。调用它就相当于调用 Promise.prototype. then(null, onRejected)。
let p = Promise.reject();
let onRejected = function(e) {
setTimeout(console.log, 0, 'rejected');
};
// 这两种添加拒绝处理程序的方式是一样的:
p.then(null, onRejected); // rejected
p.catch(onRejected); // rejected
**Promise.prototype.catch()**返回一个新的实例:
let p1 = new Promise(() => {});
let p2 = p1.catch();
setTimeout(console.log, 0, p1); // Promise
setTimeout(console.log, 0, p2); // Promise
setTimeout(console.log, 0, p1 === p2); // false
在返回新的promise实例方面,该方法的行为和promise.prototype.then()
的onRejected处理程序一致。
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); // Finally
p2.finally(onFinally); // Finally
Promise.prototype.finally()方法返回一个新的期约实例:
let p1 = new Promise(() => {});
let p2 = p1.finally();
setTimeout(console.log, 0, p1); // Promise
setTimeout(console.log, 0, p2); // Promise
setTimeout(console.log, 0, p1 === p2); // false
这个实例方法不同于then和catch
方法返回的实例,因为onFinally被设计为一个状态无关的方法,所以在大多数情况下,它将表现为父Promise的传递。对于已经解决和被拒绝的状态 都是如此。
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, p2); // Promise : foo
setTimeout(console.log, 0, p3); // Promise : foo
setTimeout(console.log, 0, p4); // Promise : foo
setTimeout(console.log, 0, p5); // Promise : foo
setTimeout(console.log, 0, p6); // Promise : foo
setTimeout(console.log, 0, p7); // Promise : foo
setTimeout(console.log, 0, p8); // Promise : foo