目录
- 11.1 异步编程
- 11.12 期约
-
- 11.2.1 Promises/A+规范
- 11.2.2 期约基础
- 11.2.3 期约的实例方法
-
- 1. 实现 Thenable 接口 (了解, 几乎不用)
- 2. Promise.prototype.then(onResolvedFunction, onRejectedFunction), 返回一个新的期约实例
- 3. Promise.prototype.catch(), 添加拒绝处理程序
- 4. Promise.prototype.finally(), 在解决或拒绝状态时执行, 添加onFinally处理程序
- 5. 非重入期约方法
- 6. 邻近处理程序的执行顺序- 按照添加顺序依次执行
- 7. 传递解决值(value, resolved/fulfilled状态)和拒绝理由(reason, rejected状态)
- 8. 拒绝期约与拒绝错误处理
- 11.2.4 期约连锁与期约合成
- 11.2.5 期约扩展(ES6还未实现,拓展性了解)
- 11.3 异步函数
- 11.4 小结
ECMAScript 6
及之后的几个版本逐步加大了对异步编程机制的支持,提供了令人眼前一亮的新特性。
ECMAScript 6
新增了正式的 Promise
(期约)引用类型,支持优雅地定义和组织异步逻辑。
接下来几个版本增加了使用 async
和 await
关键字定义异步函数的机制。
11.1 异步编程
同步行为和异步行为的对立统一是计算机科学的一个基本概念。
特别是在 JavaScript
这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。异步行为是为了优化因计算量大而时间长的操作。如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做就是务实的。
重要的是,异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。
11.1.1 同步与异步
同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。
同步操作的例子可以是执行一次简单的数学计算:
let x = 3;
x = x + 4;
在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。等到最后一条指定执行完毕,存储在 x
的值就立即可以使用。
这两行 JavaScript
代码对应的低级指令(从 JavaScript
到 x86
)并不难想象。首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态。
相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。
异步操作的例子可以是在定时回调中执行一次简单的数学计算:
let x = 3;
setTimeout(() => x = x + 4, 1000);
这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道 x
值何时会改变,因为这取决于回调何时从消息队列出列并执行。
异步代码不容易推断。虽然这个例子对应的低级代码最终跟前面的例子没什么区别,但第二个指令块(加操作及赋值操作)是由系统计时器触发的,这会生成一个入队执行的中断。到底什么时候会触发这个中断,这对 JavaScript
运行时来说是一个黑盒,因此实际上无法预知(尽管可以保证这发生在当前线程的同步代码执行之后,否则回调都没有机会出列被执行)。无论如何,在排定回调以后基本没办法知道系统状态何时变化。
为了让后续代码能够使用 x
,异步执行的函数需要在更新 x
的值以后通知其他代码。如果程序不需要这个值,那么就只管继续执行,不必等待这个结果了。
设计一个能够知道 x
什么时候可以读取的系统是非常难的。JavaScript
在实现这样一个系统的过程中也经历了几次迭代。
11.1.2 以往的异步编程模式
异步行为是 JavaScript
的基础,但以前的实现不理想。在早期的 JavaScript
中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。
假设有以下异步函数,使用了 setTimeout
在一秒钟之后执行某些操作:
function double(value) {
setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
}
double(3);
// 6(大约 1000 毫秒之后)
关键是理解为什么说上面的代码是一个异步函数。
setTimeout
可以定义一个在指定时间之后会被调度执行的回调函数。对这个例子而言,1000 毫秒之后,JavaScript
运行时会把回调函数推到自己的消息队列上去等待执行。推到队列之后,回调什么时候出列被执行对 JavaScript
代码就完全不可见了。还有一点,double()
函数在 setTimeout
成功调度异步操作之后会立即退出。
1. 异步返回值
假设 setTimeout
操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?
广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。
function double(value, callback) {
setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`I was given: ${
x}`));
// I was given: 6(大约 1000 毫秒之后)
这里的 setTimeout
调用告诉 JavaScript
运行时在 1000 毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。
2. 失败处理
异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调:
function double(value, success, failure) {
setTimeout(() => {
try {
if (typeof value !== 'number') {
throw 'Must provide number as first argument';
}
success(2 * value);
} catch (e) {
failure(e);
}
}, 1000);
}
const successCallback = (x) => console.log(`Success: ${
x}`);
const failureCallback = (e) => console.log(`Failure: ${
e}`);
double(3, successCallback, failureCallback);
double('b', successCallback, failureCallback);
// Success: 6(大约 1000 毫秒之后)
// Failure: Must provide number as first argument(大约 1000 毫秒之后)
这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。
3. 嵌套异步回调
如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这就要求嵌套回调:
function double(value, success, failure) {
setTimeout(() => {
try {
if (typeof value !== 'number') {
throw 'Must provide number as first argument';
}
success(2 * value);
} catch (e) {
failure(e);
}
}, 1000);
}
const successCallback = (x) => {
double(x, (y) => console.log(`Success: ${
y}`));
};
const failureCallback = (e) => console.log(`Failure: ${
e}`);
double(3, successCallback, failureCallback);
// Success: 12(大约 1000 毫秒之后) 3*2*2
显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓名至实归。嵌套回调的代码维护起来就是噩梦。
11.12 期约
11.2.1 Promises/A+规范
ECMAScript 6
增加了对 Promises/A+
规范的完善支持,即 Promise
类型。一经推出,Promise
就大受欢迎,成为了主导性的异步编程机制。所有现代浏览器都支持 ES6
期约,很多其他浏览器 API
(如fetch()
和 Battery Status API
)也以期约为基础。
11.2.2 期约基础
ECMAScript 6
新增的引用类型 Promise
,可以通过 new
操作符来实例化。
创建新期约时需要传入执行器(executor
)函数作为参数。
下面的例子使用了一个空函数对象来应付一下解释器:
let p = new Promise(() => {
});
setTimeout(console.log, 0, p); // Promise <pending>
之所以说是应付解释器,是因为如果不提供执行器函数,就会抛出SyntaxError
。
1. 期约状态机(待定pending, 兑现resolved/fulfilled, 拒绝rejected)
在把一个期约实例传给 console.log()
时,控制台输出(可能因浏览器不同而略有差异)表明该实例处于待定(pending
)状态。
期约是一个有状态的对象,可能处于如下 3 种状态之一:
待定(pending)- (尚未开始或者正在执行中)
兑现(fulfilled,有时候也称为“解决”,resolved)- (已经成功完成)
拒绝(rejected)- (没有成功完成)
待定(pending
)是期约的最初始状态。在待定状态下,期约可以落定(settled
)为代表成功的兑现(fulfilled
)状态,或者代表失败的拒绝(rejected
)状态。
无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变(即期约的状态只能改变一次)。而且,也不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决(resolve
)还是拒绝(reject
),甚至永远处于待定(pending
)状态,都应该具有恰当的行为。
重要的是,期约的状态是私有的,不能直接通过JavaScript
检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。另外,期约的状态也不能被外部 JavaScript
代码修改。这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。
2. 解决值value、拒绝理由reason及期约用例
期约主要有两大用途。
首先是抽象地表示一个异步操作。期约的状态代表期约是否完成。“待定pending
”表示尚未开始或者正在执行中。“兑现resolved / fulfilled
”表示已经成功完成,而“拒绝rejected
’”则表示没有成功完成。
某些情况下,这个状态机就是期约可以提供的最有用的信息。知道一段异步代码已经完成,对于其他代码而言已经足够了。
比如,假设期约要向服务器发送一个 HTTP
请求。请求返回 200~299
范围内的状态码就足以让期约的状态变为“兑现resolved / fulfilled
”。
类似地,如果请求返回的状态码不在 200~299
这个范围内,那么就会把期约状态切换为“拒绝rejected
”。
在另外一些情况下,期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问这个值。相应地,如果期约被拒绝,程序就会期待期约状态改变时可以拿到拒绝的理由。
比如,假设期约向服务器发送一个 HTTP
请求并预定会返回一个 JSON
。如果请求返回范围在 200~299
的状态码,则足以让期约的状态变为“兑现resolved / fulfilled
”。此时期约内部就可以收到一个 JSON
字符串。
类似地,如果请求返回的状态码不在 200~299
这个范围内,那么就会把期约状态切换为“拒绝rejected
”。此时拒绝的理由可能是一个Error
对象,包含着 HTTP
状态码及相关错误消息。
为了支持这两种用例,每个期约只要状态切换为“兑现resolved / fulfilled
”,就会有一个私有的内部值(value
)。类似地,每个期约只要状态切换为“拒绝rejected
”,就会有一个私有的内部理由(reason
)。
无论是解决值value
还是拒绝理由reason
,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为undefined
。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。
3. 通过执行函数executor 即 new Promise(executor) 控制期约状态
由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。
执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。
其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 resolve()
和 reject()
。调用resolve()
会把状态切换为兑现,调用 reject()
会把状态切换为拒绝。另外,调用 reject()
会抛出错误。
let p1 = new Promise((resolve, reject) => resolve());
setTimeout(console.log, 0, p1); // Promise <resolved>
let p2 = new Promise((resolve, reject) => reject());
setTimeout(console.log, 0, p2); // Promise <rejected>
// Uncaught error (in 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延迟1000ms
setTimeout(console.log, 0, p); // Promise <pending>
let p = new Promise((resolve, reject) => setTimeout(resolve, 0));
// 在 console.log 打印期约实例的时候,已经执行超时回调(即 resolve()), setTimeout无延迟, 延迟0ms
setTimeout(console.log, 0, p); // Promise <pending>
无论 resolve()
和 reject()
中的哪个被调用,状态转换都不可撤销了, 成为resolved/fulfilled
或者 rejected
以后不可再改变。
于是继续修改状态会静默失败,如下所示:
let p = new Promise((resolve, reject) => {
resolve();
reject(); // 没有效果 因为已经被resolve()执行完已经是完成状态, 不会再改变
});
setTimeout(console.log, 0, p); // Promise <resolved>
为避免期约卡在待定状态,可以添加一个定时退出功能。
比如,可以通过 setTimeout
设置一个10秒钟后无论如何都会拒绝期约的回调:
let p = new Promise((resolve, reject) => {
setTimeout(reject, 10000); // 10 秒后调用 reject()
// 执行函数的逻辑
});
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 11000, p); // 11 秒后再检查状态
// (After 10 seconds) Uncaught error
// (After 11 seconds) Promise <rejected>
因为期约的状态只能改变一次,所以这里的超时拒绝逻辑中可以放心地设置让期约处于待定状态的最长时间。如果执行器中的代码在超时之前已经解决resolved/fulfilled
或拒绝rejected
,那么超时回调再尝试拒绝也会静默失败。
4. Promise.resolve(), 实例化一个解决(resolved/fulfilled)的期约,具有幂等性
期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve()
静态方法,可以实例化一个解决(resolved/fulfilled
)的期约。
下面两个期约实例实际上是一样的:
let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();
这个解决的期约的值对应着传给 Promise.resolve()
的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个期约:
setTimeout(console.log, 0, Promise.resolve());
// Promise <resolved>: undefined
setTimeout(console.log, 0, Promise.resolve(3));
// Promise <resolved>: 3
// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4, 5, 6));
// Promise <resolved>: 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
幂等操作: 在编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。
幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的。更复杂的操作幂等保证是利用唯一交易号(流水号)实现。
Promise.resolve()
的幂等性会保留传入期约的状态:
let p = new Promise(() => {
});
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 0, Promise.resolve(p)); // Promise <pending>
setTimeout(console.log, 0, p === Promise.resolve(p)); // true
注意,这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。因此,也可能导致不符合预期的行为:
let p = Promise.resolve(new Error('foo'));
setTimeout(console.log, 0, p);
// Promise <resolved>: Error: foo
5. Promise.reject(), 实例化一个拒绝(rejected)的期约, 并将传参作为返回拒绝的理由
与 Promise.resolve()
类似,Promise.reject()
会实例化一个拒绝rejected
的期约并抛出一个异步错误(这个错误不能通过 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 <rejected>: 3
// 拒绝的理由作为传参, 也会传给后续的拒绝处理程序
p.then(null, (e) => setTimeout(console.log, 0, e)); // 3
关键在于,Promise.reject()
并没有照搬 Promise.resolve()
的幂等逻辑。如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由:
setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <resolved>
6. 同步/异步执行的二元性
Promise
的设计很大程度上会导致一种完全不同于 JavaScript
的计算模式。
下面的例子完美地展示了这一点,其中包含了两种模式下抛出错误的情形:
try {
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
抛出并捕获了错误,第二个 try/catch
抛出错误却没有捕获到。
乍一看这可能有点违反直觉,因为代码中确实是同步创建了一个拒绝reject()
的期约实例,而这个实例也抛出了包含拒绝理由的错误。这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
在上面的例子中,拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,try/catch
块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法。
11.2.3 期约的实例方法
期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。
1. 实现 Thenable 接口 (了解, 几乎不用)
在 ECMAScript 暴露的异步结构中,任何对象都有一个 then()
方法。这个方法被认为实现了Thenable
接口。
下面的例子展示了实现这一接口的最简单的类:
class MyThenable {
then() {
}
}
ECMAScript
的 Promise
类型实现了 Thenable
接口。这个简化的接口跟 TypeScript
或其他包中的接口或类型定义不同,它们都设定了 Thenable
接口更具体的形式。
注意 本章后面再介绍异步函数时还会再谈到
Thenable
接口的用途和目的。
2. Promise.prototype.then(onResolvedFunction, onRejectedFunction), 返回一个新的期约实例
Promise.prototype.then()
是为期约实例添加处理程序的主要方法。
这个 then()
方法接收最多两个参数:onResolved
处理程序和onRejected
处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现resolved/fulfilled
”和“拒绝rejected
”状态时执行。
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));
// then方法接收两个参数, onResolved处理程序和onRejected处理程序
p1.then(() => onResolved('p1'),
() => onRejected('p1'));
p2.then(() => onResolved('p2'),
() => onRejected('p2'));
//(3 秒后)
// p1 resolved
// p2 rejected
因为期约只能转换为最终状态一次,所以这两个操作一定是互斥的。
如前所述,then()
方法的两个处理程序参数都是可选的。而且,传给 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 处理程序的规范写法, 在onResolved参数的位置上写null
p2.then(null, () => onRejected('p2'));
// p2 rejected(3 秒后)
Promise.prototype.then()
方法返回一个新的期约实例。
// Promise.prototype.then()返回一个新的期约实例
let p1 = new Promise(() => {
});
let p2 = p1.then();
setTimeout(console.log, 0, p1); // Promise <pending>
setTimeout(console.log, 0, p2); // Promise <pending>
setTimeout(console.log, 0, p1 === p2); // false
(1) onResolved处理程序
这个新期约实例基于 onResovled
处理程序的返回值构建。
换句话说,该onResovled
处理程序的返回值会通过Promise.resolve()
包装来生成新期约。
如果没有提供这个处理程序,则原样向后传, 即Promise.resolve()
会包装上一个期约解决之后的值。
如果没有显式的返回语句,则Promise.resolve()
会包装默认的返回值 undefined
。
let p1 = Promise.resolve('foo');
// 若调用 then()时不传处理程序,则原样向后传
// 没有提供处理程序,则 `Promise.resolve()`就会包装上一个期约解决之后的值
let p2 = p1.then();
setTimeout(console.log, 0, p2); // Promise <resolved>: foo
// 这些都一样
// 如果没有显式的返回语句,则`Promise.resolve()`会包装默认的返回值 `undefined`
let p3 = p1.then(() => undefined);
let p4 = p1.then(() => {
});
let p5 = p1.then(() => Promise.resolve());
setTimeout(console.log, 0, p3); // Promise <resolved>: undefined
setTimeout(console.log, 0, p4); // Promise <resolved>: undefined
setTimeout(console.log, 0, p5); // Promise <resolved>: undefined
如果有显式的返回值,则 Promise.resolve()
会包装这个值,且Promise.resolve()
保留返回的期约:
let p1 = Promise.resolve('foo');
// 这些都一样
// 如果有显式的返回值,则 `Promise.resolve()`会包装这个值
let p6 = p1.then(() => 'bar');
let p7 = p1.then(() => Promise