一、期约与异步函数
1. 异步编程
(1) 同步与异步
同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。
异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。let x = 3; setTimeout(() => x = x + 4, 1000);
为了让后续代码能够使用 x,异步执行的函数需要在更新 x 的值以后通知其他代码。如果程序不需要这个值,那么就只管继续执行,不必等待这个结果了。
(2) 以往的异步编程模式
在早期的 JavaScript 中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。
function double(value) { setTimeout(() => setTimeout(console.log, 0, value * 2), 1000); } double(3); // 6(大约 1000 毫秒之后)
对这个例子而言, 1000 毫秒之后, JavaScript 运行时会把回调函数推到自己的消息队列上去等待执行。推到队列之后,回调什么时候出列被执行对 JavaScript 代码就完全不可见了。还有一点, double()函数在 setTimeout 成功调度异步操作之后会立即退出。
异步返回值:
假设 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 毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。
失败处理:
异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调: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 毫秒之后)
这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。
嵌套异步回调:
如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓名至实归。嵌套回调的代码维护起来就是噩梦。
2. 期约基础
(1) 期约状态机
期约是一个有状态的对象,可能处于如下 3 种状态之一
待定(pending)
兑现( fulfilled,有时候也称为“解决”, resolved)
拒绝( rejected)
只要从待定转换为兑现或拒绝,期约的状态就不再改变。
(2) 解决值、拒绝理由及期约用例
期约主要有两大用途。首先是抽象地表示一个异步操作。期约的状态代表期约是否完成。
“待定”表示尚未开始或者正在执行中;
“兑现”表示已经成功完成;
“拒绝”则表示没有成功完成。
其次,期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问这个值。相应地,如果期约被拒绝,程序就会期待期约状态改变时可以拿到拒绝的理由。
(3) 通过执行函数控制期约状态
其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 resolve()和reject()。调用resolve()会把状态切换为兑现,调用 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)
(3) Promise.resolve()
通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。
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
对这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似于一个空包装。因此,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
这个幂等性会保留传入期约的状态:
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
(4) Promise.reject()
Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获)。这个拒绝的期约的理由就是传给 Promise.reject()的第一个参数。这个参数也会传给后续的拒绝处理程序。Promise.reject()并没有照搬 Promise.resolve()的幂等逻辑。如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由。
(5) 同步/异步执行的二元性
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 抛出错误却没有捕获到。乍一看这可能有点违反直觉,因为代码中确实是同步创建了一个拒绝的期约实例,而这个实例也抛出了包含拒绝理由的错误。这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
在前面的例子中,拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此, try/catch 块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法。
3. 期约的实例方法
期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。
(1) 实现 Thenable 接口
在 ECMAScript 暴露的异步结构中,任何对象都有一个 then()方法。这个方法被认为实现了Thenable 接口。下面的例子展示了实现这一接口的最简单的类:
class MyThenable { then() {} }
ECMAScript 的 Promise 类型实现了 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
如果想只提供 onRejected 参数,那就要在 onResolved 参数的位置上传入 null。
Promise.prototype.then()方法返回一个新的期约实例:
let p1 = new Promise(() => {}); let p2 = p1.then(); setTimeout(console.log, 0, p2); // Promise <pending> setTimeout(console.log, 0, p1 === p2); // false
这个新期约实例基于 onResovled 处理程序的返回值构建。换句话说,该处理程序的返回值会通过Promise.resolve()包装来生成新期约。如果没有提供这个处理程序,则 Promise.resolve()就会包装上一个期约解决之后的值。如果没有显式的返回语句,则 Promise.resolve()会包装默认的返回值 undefined。
onRejected 处理程序也与之类似: onRejected 处理程序返回的值也会被Promise.resolve()包装。乍一看这可能有点违反直觉,但是想一想, onRejected 处理程序的任务不就是捕获异步错误吗?因此,拒绝处理程序在捕获错误后不抛出异常是符合期约的行为,应该返回一个解决期约。
(3) Promise.prototype.catch()
Promise.prototype.catch()方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用Promise.prototype.then(null, onRejected)。
Promise.prototype.catch()返回一个新的期约实例,在返回新期约实例方面,Promise.prototype.catch()的行为与 Promise.prototype.then()的 onRejected 处理程序是一样的。
(4) Promise.prototype.finally()
Promise.prototype.finally()方法用于给期约添加 onFinally 处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。当Promise处于pending之外的状态都要执行某个程序,即可使用finally。这个方法可以避免 onResolved 和onRejected 处理程序中出现冗余代码。
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 <pending> setTimeout(console.log, 0, p2); // Promise <pending> setTimeout(console.log, 0, p1 === p2); // false
这个新期约实例不同于 then()或 catch()方式返回的实例。因为 onFinally 被设计为一个状态无关的方法, 所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。如果返回的是一个待定的期约,或者 onFinally 处理程序抛出了错误(显式抛出或返回了一个拒绝期约),则会返回相应的期约(待定或拒绝)
(5) 非重入期约方法
当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。
let p = Promise.resolve(); // 创建解决的期约 // 添加解决处理程序,直觉上,这个处理程序会等期约一解决就执行 p.then(() => console.log('onResolved handler')); // 同步输出,证明 then()已经返回 console.log('then() returns'); // 实际的输出: // then() returns // onResolved handler
在这个例子中,在一个解决期约上调用 then()会把 onResolved 处理程序推进消息队列。但这个处理程序在当前线程上的同步代码执行完成前不会执行。因此,跟在then()后面的同步代码一定先于处理程序执行。先添加处理程序后解决期约也是一样的。
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
let p = new Promise((resolve, reject) => { console.log('first'); resolve('second'); }); p.then((res) => {console.log(res); return Promise.resolve('third')}) .then((res) => {console.log(res); return Promise.resolve('fourth')}) .then((res) => {console.log(res)}); // first // second // third // fourth
(2) 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(); // TypeError: cannot read Symbol.iterator of undefined
合成的期约只会在每个包含的期约都解决之后才解决,如果至少有一个包含的期约待定,则合成的期约也会待定。如果有一个包含的期约拒绝,则合成的期约也会拒绝。如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,没有则为undefined,按照迭代器顺序。如果有期约拒绝,则第一个拒绝的期约会将自己的理由作为合成期约的拒绝理由。
(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(); // TypeError: cannot read Symbol.iterator of undefined
Promise.race()不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约, Promise.race()就会包装其解决值或拒绝理由并返回新期约。
5. 期约扩展
(1) 期约取消
很多第三方期约库实现中具备而 ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。
(2) 期约进度通知
很多第三方期约库实现中具备而 ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。
6. 异步函数
(1) async
async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上。使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通 JavaScript 函数的正常行为。正如下面的例子所示, foo()函数仍然会在后面的指令之前被求值:
async function foo() { console.log(1); } foo(); console.log(2); // 1 // 2
不过,异步函数如果使用 return 关键字返回了值(如果没有 return 则会返回 undefined),这个值会被 Promise.resolve()包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约。直接返回一个期约对象也是一样的。在异步函数中抛出错误会返回拒绝的期约。
(2) await
因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await关键字可以暂停异步函数代码的执行,等待期约解决。
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3)); p.then((x) => console.log(x)); // 3
使用 async/await 可以写成这样:
async function foo() { let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3)); console.log(await p); } foo(); // 3
注意, await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。这个行为与生成器函数中的 yield 关键字是一样的。 await 关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。await 关键字的用法与 JavaScript 的一元操作一样。它可以单独使用,也可以在表达式中使用。等待会抛出错误的同步操作,会返回拒绝的期约。
(3) await的限制
await 关键字必须在异步函数中使用,不能在顶级上下文如< script >标签或模块中使用。不过,定义并立即调用异步函数是没问题的。此外,异步函数的特质不会扩展到嵌套函数。因此, await 关键字也只能直接出现在异步函数的定义中。在同步函数内部使用 await 会抛出 SyntaxError。
(4) 停止和恢复执行
async/await 中真正起作用的是 await。 async 关键字,无论从哪方面来看,都不过是一个标识符。毕竟,异步函数如果不包含 await 关键字,其执行基本上跟普通函数没有什么区别。
要完全理解 await 关键字,必须知道它并非只是等待一个值可用那么简单。JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了, JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。因此,即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值。
async function foo() { console.log(2); await null; console.log(4); } console.log(1); foo(); console.log(3); // 1 // 2 // 3 // 4
(1) 打印 1;
(2) 调用异步函数 foo();
(3)(在 foo()中)打印 2;
(4)(在 foo()中) await 关键字暂停执行,为立即可用的值 null 向消息队列中添加一个任务;
(5) foo()退出;
(6) 打印 3;
(7) 同步线程的代码执行完毕;
(8) JavaScript 运行时从消息队列中取出任务,恢复异步函数执行;
(9)(在 foo()中)恢复执行, await 取得 null 值(这里并没有使用);
(10)(在 foo()中)打印 4;
(11) foo()返回。
如果 await 后面是一个期约,执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。TC39 对 await 后面是期约的情况修改成只会生成一个任务。
(5) 异步函数策略
① 实现 sleep()
async function sleep(delay) { return new Promise((resolve) => setTimeout(resolve, delay)); } async function foo() { const t0 = Date.now(); await sleep(1500); // 暂停约 1500 毫秒 console.log(Date.now() - t0); } foo(); // 1502
② 利用平行执行
如果顺序不是必需保证的,那么可以先一次性初始化所有期约,然后再分别等待它们的结果。比如:
async function randomDelay(id) { // 延迟 0~1000 毫秒 const delay = Math.random() * 1000; return new Promise((resolve) => setTimeout(() => { console.log(`${id} finished`); resolve(); }, delay)); } async function foo() { const t0 = Date.now(); const promises = Array(5).fill(null).map((_, i) => randomDelay(i)); for (const p of promises) { await p; } console.log(`${Date.now() - t0}ms elapsed`); } foo(); // 4 finished // 2 finished // 1 finished // 0 finished // 3 finished // 877ms elapsed
虽然期约没有按照顺序执行,但 await 按顺序收到了每个期约的值
③ 串行执行期约
使用 async/await,期约连锁会变得很简单
async function addTwo(x) {return x + 2;} //function addTwo(x) {return x + 2;} async function addThree(x) {return x + 3;} //function addThree(x) {return x + 3;} async function addFive(x) {return x + 5;} //function addFive(x) {return x + 5;} async function addTen(x) { for (const fn of [addTwo, addThree, addFive]) { x = await fn(x); } return x; } addTen(9).then(console.log); // 19
以上内容摘自《JavaScript高级程序设计》第四版