笔记内容含异步编程概念、早期异步编程方式的弊端、ES6 新增的 Promise 引用类型用于定义和组织异步逻辑的相关规范,最后学习一种 ES8 新增的用于组织异步代码的异步结构——异步函数。前两个方面只记下核心思想概念,着重学习后两种利用异步结构组织代码的异步解决方案。来自《JavaScript 高级程序设计》(第四版)的阅读笔记内容。
ECMAScript 6 及之后的几个版本逐步加大了对异步编程机制的支持。ES6 新增了正式的 Promise(期约)引用类型,支持优雅地定义和组织异步逻辑。接下来的几个版本增加了使用 async 和 await 关键字定义异步函数的机制。
首先分享一篇详细介绍 JS 执行机制的文章 JS 多线程和 Event Loop。同步行为和异步行为的对立统一是计算机科学的一个基本概念。特别是在 JavaScript 这种单线程事件循环模型中,同步操作和异步操作更是代码所要依赖的核心机制。异步操作是为了优化计算量大而耗时的操作,只要是不想因为等待某个异步操作而阻塞线程的执行,那么就可以使用异步操作。
同步行为:同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照他们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。
异步行为:类似于系统中断,当前进程外部的实体可以触发代码执行。异步行为指令(例如设置定时器)执行后,无法在线程立即执行回调并获取结果。
let a = 1;
setTimeout(() => a = 2, 0);
console.log(a); // 1
setTimeout(() => { console.log(a) }, 1000); // 2
上面的示例在这一次执行线程中无法知道 a 的值何时会改变,因为这取决于回调何时从消息队列出列并执行。修改变量 a 的值的指令块是由系统计时器触发的,生成一个入队执行的中断。但是何时触发这个中断对 JS 运行时来说是一个黑盒(只能保证是在当前线程的同步代码执行之后,而无法预知系统何时变化)。
若后续代码依赖异步操作的执行结果,则异步执行的函数需要在更新结果后通知其他代码。其他代码的执行需要等待这个结果(这是无法改变的必然的)。设计一个能够知道何时读取异步操作结果的系统是非常难的,JavaScript 在实现这样一个系统的过程中也经历了几次迭代。
早期的 JavaScript 只支持定义回调函数来表明异步操作完成,并通过回调来获取处理异步操作的执行结果。
const fs = require('fs');
// 读取 test0.text 文件内容并打印
fs.readFile('test0.text', 'utf8', (err, doc) => {
err ? console.log(err) : console.log(doc);
});
// 读取 test1.text 文件内容并打印
fs.readFile('test1.text', 'utf8', (err, doc) => {
err ? console.log(err) : console.log(doc);
});
// 读取 test2.text 文件内容并打印
fs.readFile('test2.text', 'utf8', (err, doc) => {
err ? console.log(err) : console.log(doc);
});
// let fs3 = fs.readFile('test2.text', 'utf8'); // 无法直接获取结果
文件读取操作是异步操作,如上面那样成功调度三个异步操作,JavaScript 无法立即得到文件内容。只有当文件读取完成,将接收结果的回调推入消息队列上去等待执行,并在执行栈清空同步任务后执行回调。而在什么时候读取完内容?首先读取完那个文件的内容?这些对 JavaScript 代码就是完全不可见的了(异步操作的体现,在代码里面无法直接获取结果)。具体的体现是,上述代码在执行时,如果文件大小相差不大,那么读取结果的打印顺序是随机的。 如图为我执行三次产生的随机结果。
早期的这种在初始化异步操作时定义回调。并将异步操作结果(返回值)作为回调函数的参数,然后通过回调进行最终处理的模式已经不可取了。如果一个异步的返回值依赖另一个异步的返回值,就会要求嵌套回调来解决异步编程问题。如果串联多个异步,则需要深度嵌套回调函数(俗称“回调地狱”)。举个例子:要求上面 3 个文件依次读取,上面的示例的逻辑是前面的文件可能还没读取完(状态不可知)就开始读取下面的文件。
const fs = require('fs');
fs.readFile('test0.text', 'utf8', (err, doc0) => {
console.log(doc0);
fs.readFile('test1.text', 'utf8', (err, doc1) => {
console.log(doc1);
fs.readFile('test2.text', 'utf8', (err, doc2) => {
console.log(doc2);
});
});
});
这种异步编程方式的问题:多层嵌套,代码缩进条理不清晰,代码逻辑也不明确,还有返回数据结果的参数命名问题。
期约(Promise)是对尚不存在结果的一个替身,描述的是一种异步程序执行的机制。Promise/A+ 规范是 Promise/A+ 组织在 CommonJS 项目实现的 Promise/A 规范的基础上制定的异步编程规范。ECMAScript 6 增加了对该规范的完善支持,即实现了 Promise 引用类型。现在 Promise 已成为主导性的异步编程机制。所有现代浏览器都支持 Promise ,很多浏览器 API(例如 fetch)也是以 Promise 为基础实现。
1、promise 对象的创建
作为 ES6 新增的引用类型,promise 对象跟其他引用类型一样可以使用 new 操作符后构造函数来实例化。但是创建新期约时必须要传入执行器(executor)函数作为参数,不然会报错。
// 使用空函数作为执行器函数
console.log(new Promise(() => {})); // Promise {}
2、期约状态机(重点)
期约是一个有状态的对象,它可能处于以下三种状态。
重点1:期约从待定状态无论是落定为兑现还是拒绝状态都是不可逆的。只要从待定状态转换为兑现或者拒绝,期约的状态就不会再改变。而且,也不能保证期约必然会脱离待定状态。所以,组织合理的代码无论是哪个状态都应该具有恰当的行为。
重点2:期约的状态是私有的,不能直接通过 JavaScript 检测到。主要是为了避免根据读取到的期约状态,以同步的方式处理期约对象(违背初衷)。期约状态也不能被外部 JavaScript 代码修改(期约故意将异步行为封装起来,从而隔离外部同步代码)。
3、解决值、拒绝理由及期约用例
期约抽象地表示了一个异步操作。一方面期约状态机提供了异步操作的执行情况(是否已经完成,是成功完成还是出现异常而没有成功完成)。另一方面期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问到这个值。相应的如果期约没有成功完成,也希望期约在变为拒绝状态时可以拿到拒绝的理由。比如,假设让期约向服务器发送一个 http 请求。如果返回 200-299范围内的状态码则将期约状态变为兑现,并在期约内部收到服务器返回的响应体。如果返回的状态码不在这个范围,则期约应该落定为拒绝,此时拒绝的理由可能是一个错误对象。
为了支持这两种用例,每个期约只要落定为兑现,就会有一个私有的内部值,只要落定为拒绝则有一个私有的内部理由。在期约到达某个落定状态时执行的异步代码始终会收到这个值或者理由(默认为 undefined)。
4、期约的执行器函数
期约的执行器函数的作用:1. 初始化期约的异步行为。 2. 控制期约状态的最终转换。期约状态是私有的,只能在内部进行操作,即在执行器函数中进行状态切换。控制期约状态切换时通过调用执行器函数的两个函数参数实现的。这两个参数通常命名为 resolve() 和 reject(),在使用执行器函数初始化期约时,调用他们分别可以将期约切换为兑现和拒绝状态。
// 示例1:
// pending 状态没有值
console.log(new Promise(() => setTimeout(console.log, 0, 'executor'))); // 'executor' Promise {}
// fulfilled 状态值默认 undefined
console.log(new Promise(resolve => resolve())); // Promise {: undefined}
// rejected 状态值默认 undefined
console.log(new Promise((resolve, reject) => reject())); // Promise {: undefined}
// Uncaught (in promise) undefined
// 示例2:
let p = new Promise(resolve => setTimeout(resolve, 0));
// 同步打印
console.log(p); // Promise {}
// 异步打印
setTimeout(console.log, 100, p); // Promise {: undefined}
// 调用这两个函数参数时,传给他们的参数即期约的返回值(解决值或者拒绝理由)
console.log(new Promise(resolve => resolve('tkop'))); // Promise {: 'tkop'}
console.log(new Promise((resolve, reject) => reject('err1'))); // Promise {: 'err1'}
// VM518:1 Uncaught (in promise) err1
示例 1 中的 promise 对象都没有涉及异步操作,在初始化期约时,执行器函数就已经改变了每个期约的状态,所以直接打印也可以知道他们的状态。示例 2 中的是使用定时器作为异步操作并改变期约状态,所以同步打印时该期约还是待定状态。
根据期约状态落定后的不可逆性。为避免期约一直卡在待定状态,可以设置一个超时拒绝逻辑。当超过期约处于待定容许的最长时间后将其切为拒绝状态。这样如果执行器内的异步操作在未超时前就解决或者拒绝并切换相应状态时,超时逻辑在尝试切换状态也会静默失败。
let p = new Promise((resolve, reject) => {
setTimeout(reject, 10000);
// 执行其他的异步操作,中间可以改变期约的状态
// 没超时前可以改变,改变后超时回调改为拒绝状态会失败
// 超时后改变失败,已经是拒绝状态
});
setTimeout(console.log, 0, p);
setTimeout(console.log, 11000, p);
5、Promise.resolve()
通过调用期约的静态方法 resolve() 可以实例化一个解决的期约,方法中的第一个参数即对应着这个解决的期约的值。使用这个方法可以将任何值都转换为一个期约对象。
let p1 = new Promise(resolve => resolve());
let p2 = Promise.resolve();
// Promise {: undefined} Promise {: undefined}
console.log(p1, p2);
// 多余的参数会被忽略
let p3 = new Promise(resolve => resolve('期约p3的解决值', '多余的参数'));
let p4 = Promise.resolve('期约p4的解决值', '多余的参数');
// Promise {: '期约p3的解决值'} Promise {: '期约p4的解决值'}
console.log(p3, p4);
这个静态方法需要注意的点是:如果传入的参数本身是一个期约对象,那它的行为就类似一个空包装(具有幂等性),会保留期约参数的状态。如果参数本身是一个错误对象,实例化的也是一个解决的期约对象,错误对象只是该期约的解决值。
// Promise {: 'err'} 保留期约参数的状态
let p5 = Promise.resolve(new Promise((resolve, reject) => {reject('err')})); // Uncaught (in promise) err
// Promise {: Error: p6 } 返回将错误对象作为解决值得期约
let p6 = Promise.resolve(new Error('p6'));
// Promise {: Error: p7 } 在期约初始化时抛出错误则期约切为拒绝状态。
let p7 = Promise.resolve(new Promise((resolve, reject) => {throw new Error('p7')})); // Uncaught (in promise) Error: p7
console.log(p5, p6, p7);
示例中增加一个 p7 的期约是为了说明在执行器函数抛出错误能够使期约切换为拒绝状态,传入的参数是一个拒绝的期约,p6 则只是将错误对象作为参数。
6、Promise.reject()
Promise.reject() 方法会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,只能通过拒绝处理程序捕获)。期约的拒绝理由就是传给 Promise.resolve() 的第一个参数。这个参数也会传给后续的拒绝处理程序。但是如果传递的是一个期约对象,这个期约对象会成为它返回的拒绝期约的理由(跟参数期约无关)。
let p1 = new Promise((resolve, reject) => reject('3')); // Uncaught (in promise) 3
let p2 = Promise.reject('3'); // Uncaught (in promise) 3
console.log(p1, p2); // Promise {: '3'}, Promise {: '3'}
p2.then(null, (e) => console.log(e)); // 3
let p3 = Promise.reject(Promise.resolve('解决promise对象作为参数'));
console.log(p3); // Promise {: Promise}
// VM895:2 Uncaught (in promise) Promise {: '解决promise对象作为参数'}
7、同步和异步执行的二元性
内容:拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理。因此,try/catch 块并不能捕获该错误。代码一旦开启异步模式执行,则唯一与之交互的方式就是使用异步结构(期约的方法)。
let p8 = new Promise(() => {
try {
throw new Error('p8');
} catch (e) {
// console.log(e);
}
});
let p9 = null;
try {
p9 = Promise.reject(new Error('p9'));
} catch (e) {
// console.log(e);
}
console.log(p8); // Promise {}
console.log(p9); // Promise {: Error: p9
// VM1034:11 Uncaught (in promise) Error: p9
示例中 p8 是 pending 状态,因为它本质是在期约初始化时(同步代码中)抛出错误,抛出的错误对象能够被执行器函数中的 try/catch 结构捕获,所以没有影响到期约的状态。p9 则是在初始化完期约后将参数作为异步操作的返回值处理了(异步模式)。
总结:执行器函数是初始化期约的异步行为的同步代码,那两个函数参数(形参)是期约引用类型内部的静态方法(实参在内部调用时传递),本质上是 Promise.resolve() 与 Promise.reject() ,(个人想法,后面手写 Promise 时进行验证)。期约即使是在同步代码中改变其状态或传递内部值,其内部返回值的操作依旧是在异步模式下的执行结果(解决值或拒绝理由),在同步代码中无法获取(抛出的错误无法同步获取就是一个体现)。
期约的实例方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。对于 Thenable 接口了解即可,本部分核心内容是理解期约实例的这些方法的执行时机和他们的返回值(这需要明确一下上面的静态 Promise.resolve() 的返回问题)。
1、基本使用
Promise.prototype.then() 是为期约实例添加处理程序的主要方法。这个 then() 方法接收最多两个参数:onResolved 处理程序和 onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。(初学者一定要明确这两个参数与执行器函数的那两个参数的本质区别,不要因为命名类似而搞混。这个是我们为处理结果传递的实参,由我们自定义。前面执行器函数是实参,而 resolve 和 reject 只是执行器函数的形参。)
let p1 = new Promise(resolve => setTimeout(resolve, 3000, 'p1'));
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000, 'p2'));
function onResolved(result) {
console.log(result);
}
function onRejected(result) {
console.log(result);
}
p1.then(onResolved, onRejected); // 3秒后输出 'p1'
p2.then(onResolved, onRejected); // 3秒后输出 'p2'
期约只会存在一种落定结果,解决或者拒绝,所以 Promise.prototype.then() 中的两个处理程序是互斥的,只会执行其中相对应的一个。两个处理程序参数都是可选的。而且传给 then() 的任何非函数类型的参数都会被静默忽略。如果只想提供 onRejected 参数,规范的写法是在 onResolved 的位置上提供一个 null 空对象指针。
p1.then('goodBey', 'mylove'); // 都会被忽略
p2.then(null, onRejected); // 3秒后输出 'p2'
2、返回值的问题
Promise.prototype.then() 方法会返回一个基于 onResolved 或者 onRejected 处理程序的返回值构建的新期约实例。首先如果提供了对应的处理程序且具有返回值,则该处理程序的返回值会通过 Promise.resolve() 包装来生成新期约,没有显式返回语句,则包装默认返回值 undefined。其次如果没有提供对应的处理程序,则 Promise.resolve() 就会包装上一个期约值(进行一次空包装,两者相同但是不全等)。
// 声明两个处于两种状态的期约
let p_fulfilled = Promise.resolve('tkop1');
let p_rejected = Promise.reject('tkop2');
// 不提供处理程序,原样向后传递值。
let p_fulfilled0 = p_fulfilled.then();
let p_rejected0 = p_rejected.then(() => '没有提供对应的处理程序');
setTimeout(console.log, 0, p_fulfilled0); // Promise {: 'tkop1'}
setTimeout(console.log, 0, p_rejected0); // Promise {: 'tkop2'}
setTimeout(console.log, 0, p_fulfilled0 == p_fulfilled); // false
setTimeout(console.log, 0, p_rejected0 == p_rejected); // false
// 处理程序没有返回值,则包装默认返回的 undefined
let p_fulfilled1 = p_fulfilled.then(() => {});
let p_rejected1 = p_rejected.then(null, () => {});
setTimeout(console.log, 0, p_fulfilled1); // Promise {: undefined}
setTimeout(console.log, 0, p_rejected1); // Promise {: undefined}
// 处理程序有返回值,则返回值包装为新的 promise 实例
let p_fulfilled2 = p_fulfilled.then(() => '我是阿虎呀');
let p_rejected2 = p_rejected.then(null, () => '我 coc 都 14 本了');
setTimeout(console.log, 0, p_fulfilled2); // Promise {: '我是阿虎呀'}
setTimeout(console.log, 0, p_rejected2); // Promise {: '我 coc 都 14 本了'}
// 处理程序返回值为 promise 对象,无论如何都遵从 Promise.resolve() 的包装结果
let p_pending0 = p_fulfilled.then(() => new Promise(() => {}));
let p_pending1 = p_rejected.then(null,() => new Promise(() => {}));
let p_fulfilled3 = p_fulfilled.then(() => p_rejected);
let p_rejected3 = p_rejected.then(null, () => p_fulfilled);
setTimeout(console.log, 0, p_pending0); // Promise {}
setTimeout(console.log, 0, p_pending1); // Promise {}
setTimeout(console.log, 0, p_fulfilled3); // Promise {: 'tkop2'}
setTimeout(console.log, 0, p_rejected3); // Promise {: 'tkop1'}
// 在处理程序中抛出错误返回的是 reject 期约,但是单纯的返回错误对象则跟上面没区别
let p_fulfilled4 = p_fulfilled.then(() => { throw new Error('err')});
let p_rejected4 = p_rejected.then(null, () => {throw new Error('err')});
let p_returnE0 = p_rejected.then(null, () => new Error('err'));
let p_returnE1 = p_fulfilled.then(() => new Error('err'));
setTimeout(console.log, 0, p_fulfilled4); // Promise {: Error: err}
setTimeout(console.log, 0, p_rejected4); // Promise {: Error: err}
setTimeout(console.log, 0, p_returnE0); // Promise {: Error: err}
setTimeout(console.log, 0, p_returnE1); // Promise {: Error: err}
个人感觉书中的示例和描述比我的示例会更加杂乱。总结:
3、说明
可以注意到我笔记前面打印 promise 实例时使用的都是同步打印,而这里 then() 方法返回的 promise 实例,我用的都是异步打印。具体原因涉及后面的内容——非重入期约的方法。直观的说法就是,前面的示例都是使用同步代码落定了期约的状态,所以直接打印就可以准确得到他们当前的状态。但是如果是依靠异步操作落定状态,则无法通过同步打印得到准确的 promise 实例信息(例如超时拒绝逻辑)。then() 方法是异步结构,里面的回调是需要由外部线程推入任务队列并在 JavaScript 当前线程执行结束后推出执行的。
Promise.prototype.catch() 方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序。事实上他就是一个语法糖,相当于调用 Promise.prototype.then(null, onRejected)。
let p = Promise.reject();
function onRejected(result) {
console.log(result);
}
// 两者等价
p.then(null, onRejected);
p.catch(onRejected);
返回值的问题相应的也跟 then() 方法的处理方式一样。
Promise.prototype.finally() 方法用于给期约添加 onFinally 处理程序,这个处理程序会在期约状态落定的时候执行(无论时兑现还是拒绝)。这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。它无法知道期约的状态,主要用于添加清理的代码。
let p1 = Promise.resolve();
let p2 = Promise.reject();
p1.finally(() => setTimeout(console.log, 0, 'Finally'));
p2.finally(() => setTimeout(console.log, 0, 'Finally'));
Promise.prototype.finally() 的返回有点跟前面学的实例方法不同。它被设计为一个跟状态无关的方法,所以在大多数情况下将表现为父期约的传递。只需要注意在 onFinally 中返回待定期约、拒绝期约或者抛出错误的情况。
let p1 = Promise.reject('tkop');
// 不是上述三种情况,直接表现为父期约的传递。
let p2 = p1.finally();
let p3 = p1.finally(() => {});
let p4 = p1.finally(() => 'tkop1');
let p5 = p1.finally(() => Promise.resolve('tkop2'));
setTimeout(console.log, 0, p2); // Promise {: 'tkop'}
setTimeout(console.log, 0, p3); // Promise {: 'tkop'}
setTimeout(console.log, 0, p4); // Promise {: 'tkop'}
setTimeout(console.log, 0, p5); // Promise {: 'tkop'}
// 返回拒绝的期约或者抛出错误。
let p6 = p1.finally(() => Promise.reject('tkop3'));
let p7 = p1.finally(() => {throw new Error('tkop4');});
setTimeout(console.log, 0, p6); // Promise {: 'tkop3'}
setTimeout(console.log, 0, p7); // Promise {: Error: tkop4}
// 返回待定的期约,在期约解决后,新期约仍然会原样向后传初始的期约
let p8 = p1.finally(() => new Promise(() => {}));
let p9 = p1.finally(() => new Promise(resolve => setTimeout(() => resolve('tkop5'), 1000)));
setTimeout(console.log, 6000, p9); // Promise {}
setTimeout(console.log, 8000, p9); // Promise {: 'tkop'}
1、非重入期约方法
当期约落定状态时,与该状态相关的处理程序仅仅会被排期。而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的,这个特性由 JavaScript 运行时保证,被称为“非重入特性”。(书中原文)
let p = Promise.resolve();
// 添加解决处理程序
p.then(() => console.log('onResolved handler'));
// 同步输出,证明 then() 已经返回
console.log('then() returns');
// 实际输出:
// 'then() returns'
// 'onResolved handler'
在上面的示例中,在一个解决期约上调用 then() 会把 onResolved 处理程序推入消息队列(准确的说是在 p 落定为对应状态后推入)。但这个处理程序在当前线程上的同步代码执行完成前不会执行。因此。跟在 then() 后面的同步代码一定先于处理程序执行。
思考与总结:
首先 then()、catch() 等都是异步结构,都会首先返回一个 promise 实例(这里简称 p)。
p 是一个 pending 状态的期约,异步结构相当初始化了一个 p ,规定具体什么时候落定状态?返回值是什么?,但这些对 JavaScript 在当前线程来说是个黑盒。(这也是笔记前面直接可以直接打印期约查看状态,后面实例方法查看返回时都需要异步打印)。
但 p 又是和调用这些方法的期约(这里简称 P )关联的。P 的结果落定(无论是异步还是同步落定),then() 相应的处理程序被推入消息队列等待执行,执行后根据异步结构的封装逻辑改变 p 的状态和值(返回值或者拒绝理由)。
但是可能你会疑问,这样的话 P 的状态先落定后调用 then() 等方法,为什么还能执行 3 里面的逻辑呢?这个应该与发布订阅中的一个问题有关:在订阅前的发布,订阅者依旧能够接收一次发布的信息。
let p1 = new Promise(resolve => {
console.log('p1 executor'); // 第一个输出
setTimeout(resolve, 4000);
});
let p2 = p1.then(() => {console.log(p2)}); // 4秒后输出:Promise {}
console.log(p2); // 第二个输出:Promise {}
setTimeout(console.log, 2000, p2); // 2秒后输出:Promise {}
setTimeout(console.log, 5000, p2); // 5秒后输出:Promise {: undefined}
2、邻近处理程序的执行顺序
如果给期约添加了多个处理程序,当前期约状态改变时,相关处理程序会按照添加他们的顺序依次执行。无论是 then()、catch() 还是 finally() 添加的处理程序都是如此。
let p1 = Promise.reject();
// 1、2、3 按顺序输出
p1.finally(() => console.log(1));
p1.catch(() => console.log(2));
p1.then(null, () => console.log(3));
3、传递解决值和拒绝理由
期约落定状态后,会提供其解决值或者拒绝理由给相关状态的处理程序。通过相关状态处理程序进一步对这个值进行操作。
4、拒绝期约与拒绝错误处理
在期约的执行器函数或者处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。
let p1 = Promise.resolve().then(() => {throw Error('tkop1')});
let p2 = Promise.reject('tkop2').catch(() => {throw Error('tkop22')});
let p3 = new Promise(() => {throw Error('tkop3')});
setTimeout(console.log, 1000, p1);
setTimeout(console.log, 1000, p2);
setTimeout(console.log, 1000, p3);
示例结果如上图所示,所有错误都是异步抛出且未处理的,通过错误对象捕获的栈追踪信息展示了错误发生的路径。需要注意的是他们抛出的顺序,为什么 new Promise(() => {throw Error(‘tkop3’)}); 会比其他两个先抛出?因为前两个在抛出未捕获错误前它还会创建另一个期约。还有在定义 p2 时使用 Promise.reject() 也应该抛出一个错误,不过被后面的异步结构 catch() 捕获了。
异步抛出的错误并不会阻塞同步指令且只能通过通过 onRejected 处理程序捕获。
throw '这个会阻塞下面代码执行';
console.log('这不会执行');
Promise.reject('异步抛出则不会阻塞');
console.log('这里输出继续执行');
// 同步错误的捕获
try {
throw 'sync err1';
} catch (e) {}
// 注意在执行器函数里面抛出的是同步错误可以用 try/catch 捕获
// 捕获后不会因此错误而切换为拒绝状态
let p = new Promise(() => {
try {
throw 'sync err1';
} catch (e) {}
});
console.log(p);
setTimeout(console.log, 1000, p);
// 异步错误则无法通过上面的方式捕获
try {
Promise.reject('async err1');
new Promise((resolve, reject) => reject('async err2'));
} catch (e) {}
// 异步错误只能通过 then() 和 catch() 的 onRejected() 处理程序捕获。
Promise.reject('async err3').then(null, () => {});
new Promise((resolve, reject) => reject('async err4')).catch(() => {});
多个期约的组合可以构成强大的代码逻辑。可以通过两种方式实现期约组合使用:期约连锁与期约合成。期约连锁就是一个期约接一个期约地拼接调用期约方法。后者则是将多个期约组合为一个新的期约。
期约连锁实现的基础是每个期约实例的方法(then()、catch() 和 finally())都会返回一个新的期约对象,而这个新的期约又可以调用自己的实例方法。
let p = new Promise(() => {throw console.log(1);});
p.catch(() => console.log(2))
.finally(() => console.log(3))
.then(() => console.log(4));
// 依次输出 1、2、3、4
这里只是执行了一连串同步任务,下面通过让每个处理程序都返回一个期约实例,而后面处理程序都需要等前面的期约状态落定后才可以执行来串行异步任务。这里书中对示例代码的解读是错误的!先看一下代码。
仔细想想 p1 的打印内容真的是在 1 秒后打印吗?应该是立即打印(执行器函数里面的初始化代码是同步执行的),p2 则是在 p1 执行 resolve() 即 1 秒后在处理程序推入队列并出列执行时(此时在初始化一个新期约并返回)打印。后面亦是如此。所以结果应该如下所示,验证方式是将定时器时间调整得长一点。
// p1 executor (同步打印)
// p2 executor (1 秒后打印)
// p3 executor (2 秒后打印)
// p4 executor (3 秒后打印)
至于后面将返回新期约的代码进行封装也可以看看。根据自己的理解做了点小改动,不影响结果。
function onResoled(value, str) {
return new Promise(resolve => {
console.log(str);
setTimeout(resolve, 1000, value);
})
}
let p = onResoled('tkop', 'p1 executor').then((value) => onResoled(value, 'p2 executor'))
.then((value) => onResoled(value, 'p3 executor'))
.then((value) => onResoled(value, 'p4 executor'));
// p 是在 4 秒后才最终落定状态
// 1秒后:期约 1 状态落定,执行处理程序并返回期约 2。
// 2秒后:期约 2 状态落定,执行处理程序并返回期约 3。
// 3秒后:期约 3 状态落定,执行处理程序并返回期约 4。
// 4秒后:期约 4 状态落定,使用 p 接收最终的返回期约,即期约 4 使用 Promise.resolve() 封装返回。
setTimeout(console.log, 3010, p); // Promise {}
setTimeout(console.log, 4010, p); // Promise {: 'tkop'}
这种编程方式正是期约所要解决的回调地狱问题。
略过。。。。
Promise 类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all() 和 Promise.race()。合成后的期约的行为取决于内部期约的行为。
1、Promise.all() 静态方法的语法和特征。
// 只要是可迭代对象就行,可以是[]、[1, 2] 但是不能不提供(即undefined)
let p1 = Promise.all([Promise.reject(), Promise.resolve()]);
// Uncaught (in promise) TypeError:
// undefined is not iterable (cannot read property Symbol(Symbol.iterator))
let p2 = Promise.all();
// 合成的新期约只会在每个包含的期约都解决后才解决。
// 如果有一个包含期约永远待定,则合成的期约也永远待定。
let p3 = Promise.all([
new Promise(resolve => setTimeout(resolve, 5000, 1)),
new Promise(resolve => setTimeout(resolve, 1000)),
new Promise(resolve => setTimeout(resolve, 3000, 3)),
]);
setTimeout(console.log, 1500, p3); // Promise {}
// 约 5 秒后打印 Promise {: Array(3)}
p3.then(() => setTimeout(console.log, 0, p3));
// 约 5 秒后打印 [1, undefined, 3]
p3.then((value) => setTimeout(console.log, 0, value));
// 包含期约出现拒绝
let p4 = Promise.all([
new Promise((resolve, reject)=> setTimeout(reject, 7000, 1)),
new Promise(resolve => setTimeout(resolve, 1000)),
new Promise((resolve, reject)=> setTimeout(reject, 3000, 'tkop')),
]);
setTimeout(console.log, 4000, p4); // 4 秒后打印 Promise {: 'tkop'}。
p4.catch((reason) => console.log(reason)); // 约 3 秒后打印 'tkop'。
// 没有未处理的错误,第一个拒绝的期约的拒绝操作也会被静默处理。
2、Promise.race() 静态方法返回一个包装期约,是一组组合中最先解决或者拒绝的期约的镜像。它的语法与 Promise.race() 一样,不同的是不会对解决或者拒绝的期约区别对待,无论是解决还是拒绝,只要是第一个落定的期约,Promise.race() 就会包装其解决值或拒绝理由并返回新期约。
let p5 = Promise.race([
new Promise((resolve, reject)=> setTimeout(reject, 7000, 'tkop3')),
new Promise((resolve, reject)=> setTimeout(reject, 2000, 'tkop1')),
new Promise((resolve, reject)=> setTimeout(reject, 3000, 'tkop2')),
]);
setTimeout(console.log, 4000, p5); // 4 秒后打印 Promise {: 'tkop1'}。
p5.catch((reason) => console.log(reason)); // 约 2 秒后打印 'tkop1'。
// 没有未处理的错误
前面讨论的期约连锁一直围绕期约的串行执行,但是忽略了核心的使用场景:后续期约没有使用前面期约的解决值或者拒绝理由来串联期约,只是单纯的调用期约的实例方法串行异步操作。而期约异步产生值并可将其传给处理程序的特点没有很好地体现。
let p = new Promise(resolve => setTimeout(resolve, 1000, 'I'));
p.then((a) => a + ' Love')
.then((b) => b + ' You')
.then((c) => console.log(c)); // I Love You
基于后续期约使用之前期约地返回值来串联期约是期约的基本功能。类似于函数合成,将多个函数合成一个函数(提供一个参数给第一个函数使用,将前一个函数的返回值作为后面函数的实参传入使用)。
// 函数合成
let add2 = x => x + 2;
let add3 = x => x + 3;
let add5 = x => x + 5;
let add10 = x => add5(add3(add2(x)));
let add20 = x => add2(add5(add3(add10(x))));
console.log(add10(5)); // 15
console.log(add20(5)); // 25
// 期约也可以合成
let pAdd7 = (x) => Promise.resolve(x).then(add2).then(add5);
pAdd7(5).then(console.log); // 12
这种模式可以提炼成一个通用的函数用于将任意多的函数作为处理程序合成一个连续传值的期约连锁。使用 Array.prototype.reduce() 实现连续调用。
// 传入的处理函数参数必须都有一个形参用于接收前一个 promise 实例的解决值。
function compose(...fns) {
return (x) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x));
}
let pAdd10 = compose( add2, add3, add5);
pAdd10(2).then(console.log);
//当然你可以不用数组的reduce方法,有点难看。
//function compose(...fns) {
// return x => {
// let p = Promise.resolve(x);
// fns.forEach(fn => {p = p.then(fn);});
// return p;
// };
//}
略过。。。。。
异步函数(也称为 async/await 语法关键字)是 ES6 期约模式在 ECMAScript 函数中的应用。Async/await 是 ES8 规范新增的。这个特性从行为和语法上都增强了 JavaScript,让以同步方式书写的代码能够异步执行。
前面使用期约处理异步操作的编程方式还存在一个问题:程序中依赖期约解决值或者拒绝理由的其他代码都必须塞到期约的处理程序中。ES8 的 async/await 旨在解决利用异步结构组织代码的问题。为此,ECMAScript 对函数进行了扩展,为其增加了两个新的关键字:async 和 await。
1、async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和对象方法上。
async function fn0() {}
let fn1 = async () => {};
class Tk {
async handle() {}
}
2、异步函数具有异步特征的同时,其在参数或闭包方面仍然具有普通函数的正常行为。其内部代码总体上仍是同步求值的。
async function asyncFn(x) {
console.log(x + 1);
}
asyncFn(3);
console.log('tkop');
// 依次打印 4、 'tkop'
3、异步函数的返回值问题
async function asyncReturn() {
console.log('asyncReturn executed');
// 默认返回 undefined -> undefined
// return 1; // -> 1
// return 'tkop'; // -> 'tkop'
// return Promise.resolve('fulfilled promise object'); // ->'fulfilled promise object'
// throw 'rejected promise object' // -> 'rejected promise object'
// try {
// Promise.reject("this can't catch"); // Uncaught (in promise) this can't catch
// var p = Promise.reject('rejected promise object');
//} catch(e) {}
// return p; // -> 'rejected promise object(err)'
// 返回一个实现 thenable 接口的非期约对象
// return {
// then(callback) { callback('thenable interface') }
// } // -> 'thenable interface'
}
asyncReturn().then(console.log, console.log);
异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await 关键字可以暂停异步函数代码的执行,等待期约解决。
1、基本使用
// 单纯使用期约模式处理异步逻辑。
let p = new Promise(resolve => setTimeout(resolve, 5000, 5));
p.then(x => console.log('在处理程序接收处理结果:' + x));
// 期约模式结合函数使用(异步函数)
(async () => {
console.log('等待期约解决并解包:' + (await new Promise(resolve => setTimeout(resolve, 3000, 3))));
})();
// 3 秒后打印 -> 等待期约解决并解包:3
// 5 秒后打印 -> 在处理程序接收处理结果:5
可以看到整个过程没有调用 Promise 实例的 then() 方法中的处理程序进行“解包”和接收解决值。而是使用 await 关键字代替了这个过程。
2、await 的特点
// 1、await 会暂停代码执行
async function fn() {
let p = new Promise(resolve => setTimeout(resolve, 3000, 'fulfilled promise'));
console.log('无阻');
console.log(await p);
// 这里即使是立即可用的值也会是一样的结果
// console.log(await 'fulfilled promise');
console.log('受阻');
}
fn();
console.log('同步代码');
// 依次打印: '无阻' '同步代码' 'fulfilled promise' '受阻'
// 2、await 期待的数据举例(主要是抛出错误的情况)。
async function asyncFn() {
console.log('asyncFn executed');
let e = await (() => {
throw 'err';
})();
// 编辑器下面的代码会全部变暗(不会执行,无效代码)
console.log(e);
console.log('asyncFn End');
}
asyncFn().catch(() => {});
console.log('sync');
async function asyncFn1() {
console.log('asyncFn1 executed');
let e = await Promise.reject('Err');
/* 抛出的是异步错误,需要在恢复执行异步函数的任务出列执行才判断出下面的代码已是无用代码。
* 所以这里虽然不变暗,但是也不会执行。
*/
console.log(e);
console.log('asyncFn1 End');
}
asyncFn1().catch(() => {});
console.log('sync');
无论是抛出异步错误还是抛出同步任务,异步函数都会直接退出并如前面所述返回拒绝的 promise 对象。所以如果 await 关键字不会执行解包过程。异步函数抛出错误后面的代码永远不会执行。
3、await 的限制
总结:await 关键字必须在异步函数中使用。且必须由异步函数直接包裹,因为异步函数的特质不会扩展到嵌套函数。在同步函数中使用 await 会抛出语法错误(SyntaxError)。
// 均会报错
// Uncaught SyntaxError:
// await is only valid in async functions and the top level bodies of modules
function syncFn() {
console.log('syncFn executed');
await 1;
}
async function asyncFn() {
console.log('asyncFn executed');
function syncFn() {
console.log('syncFn executed');
await 1;
}
}
await 关键字并非只是等待一个值可用那么简单。JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值的可用了,JavaScript 运行时会向详细队列中推送一个任务,这个任务会恢复异步函数的执行。因此即使 await 后面跟着一个立即可用的值,函数的期约部分也会被异步求值。
如果 await 后面是一个期约,则问题会复杂一些。此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。TC39 对await 后面是期约的情况如何处理做过一次修改,新版浏览器中只会生成一个异步任务。在实际开发中,对于并行的异步操作我们通常更关注结果,而并不依赖执行顺序。也就是说,在下面的例子中,我们通常只关心异步操作中的结果是 8 和 6,并不关心他们的顺序。
async function asyncFn1() {
console.log(2);
console.log(await Promise.resolve(8));
console.log(9);
}
async function asyncFn2() {
console.log(4);
console.log(await 6);
console.log(7);
}
console.log(1);
asyncFn1();
console.log(3);
asyncFn2();
console.log(5);
// 旧版:1、2、3、4、5、6、7、8、9
// 新版:1、2、3、4、5、8、9、6、7
执行步骤(旧版):
1、实现 sleep(),通过定时器在程序中加入非阻塞的暂停。
// 返回一个待解决的期约,期约的解决时间即睡眠时间(任意)。
async function sleep(delay) {
return new Promise(resolve => setTimeout(resolve, delay));
}
async function handle() {
const t0 = Date.now();
// 程序在此 sleep 3 秒,但是不阻塞函数外其他代码执行。函数内睡眠。
await sleep(3000);
console.log(Date.now() - t0);
}
handle(); // 3002
2、利用平行执行。
如果使用 await 时不留心,则很可能错过平行加速的机会。下面顺序等待了 5 个随机的超时。
function randomDelay(id) {
// 延迟 0~1000 毫秒
const delay = Math.random() * 1000;
return new Promise(resolve =>
setTimeout(() => {
resolve(`ready pid:${id}`);
console.log(`${id}:${delay}`);
}, delay)
);
}
async function handle() {
const t0 = Date.now();
await randomDelay(0);
await randomDelay(1);
await randomDelay(2);
await randomDelay(3);
await randomDelay(4);
console.log(`总用时: ${Date.now() - t0} ms`);
}
handle();
/*
* 0:652.5993794364211
* 1:199.24043422383053
* 2:163.10211914411664
* 3:782.1054248932346
* 4:42.911400562486435
* 总用时: 1852 ms
*/
这五个期约之间并没有依赖关系,异步函数也会依次进行暂停,等待每个超时的完成。这样可以保证执行顺序,但是总执行时间会变长。
但是若他们没有依赖关系,例如请求两个没有依赖的接口数据,他们的请求顺序不用必须保证的。这两个请求是可以平行发送的,类似的这 5 个期约是可以一次性初始化的,不需要等待前面某个状态落定后再初始化并等待其结果。一次性初始化后再分别等待他们的结果,就可以大量节省等待时间。
// 平行初始化
async function handle() {
const t0 = Date.now();
const p0 = randomDelay(0);
const p1 = randomDelay(1);
const p2 = randomDelay(2);
const p3 = randomDelay(3);
const p4 = randomDelay(4);
await p0;
await p1;
await p2;
await p3;
await p4;
// 可以使用数组和 for 循环简化代码
// const promises = Array(5).fill(null).map((_, i) => randomDelay(i));
// for (let _ of promises) {
// await _;
// }
console.log(`总用时: ${Date.now() - t0} ms`);
}
handle();
/*
* 2:60.71677495706895
* 4:306.4330477221622
* 1:523.9452338073662
* 0:690.6024760159888
* 3:964.0135166186354
* 总用时: 965 ms
*/
虽然期约没有按照顺序执行,但是 await 按顺序收到了每个期约的值。类似互不依赖的几个请求可以平行发送,然后可以按期望接收到响应的结果。
async function handle() {
const t0 = Date.now();
const promises = Array(5).fill(null).map((_, i) => randomDelay(i));
for (let _ of promises) {
console.log(await _);
}
console.log(`总用时: ${Date.now() - t0} ms`);
}
handle();
/*
* 2:79.64798323065581
* 4:256.2316063175323
* 0:354.4143248389511
* ready pid:0
* 3:833.2695041288307
* 1:981.643426293545
* ready pid:1
* ready pid:2
* ready pid:3
* ready pid:4
* 总用时: 984 ms
*/
总结,在同一个异步函数内部使用多个(两个及两个以上) await 关键字时。需要考虑是否可以利用平行执行节省等待时间。
3、串行执行期约
这里没有什么难点,直接看看代码就好。
// 这里可以为异步函数也可以为同步函数
let add2 = x => x + 2;
let add3 = x => x + 3;
let add5 = x => x + 5;
async function add10(x) {
for (let fn of [add2, add3, add5]) {
x = await fn(x);
}
return x;
}
add10(2).then(console.log); // 12
4、栈追踪与内存管理
new Promise((resolve, reject) => setTimeout(reject, 2000, 'tkop'));
function executor(resolve, reject) {
setTimeout(reject, 2000, 'tkop');
}
function newPromise() {
new Promise(executor);
}
async function asyncNewPromise() {
await new Promise(executor);
}
newPromise();
asyncNewPromise();
以上示例展示了拒绝期约的栈追踪信息,简单理解一下书中表达的内容。栈追踪信息应该相当直接地表现 JavaScript 引擎当前栈内存中函数调用之间地嵌套关系。那第二个错误中就不应当出现 setTimeout() 和 executorFn() 的标识符。因为他们是用来创建最初期约实例的函数,executorFn() 用来初始化期约,定时器也是这样(规定时间改变期约状态,具体切为什么状态对于 newPromise() 是黑盒,有可能是解决)。他们在调用 newPromise() 并抛出错误之前就已经返回了。大白话就是调用创建期约函数,他们初始化一个期约并规定期约在特定时间落定状态后就返回,后续的事情与你无关。那为什么会出现呢?
JavaScript 引擎在创建期约时尽可能保留完整的调用栈。目的是在抛出错误时调用栈可以由运行时的错误处理逻辑捕获(例如我们既可以在执行器函数中使用 try/catch 同步捕获错误,也可以在外面使用 catch() 或者 then() 来捕获)。但是,这意味着栈追踪信息会占用更多的内存,从而带来一些计算和存储成本。
使用 async/await 则不同,在 asyncNewPromise() 挂起并未退出时,JavaScript 简单地在嵌套函数中存储指向包含函数地指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样不会带来额外地消耗,因此在重视性能的应用中可以优先考虑。
通过这章的学习,我们应该能基于期约和 async/await 写出更清晰、简洁和易于理解的异步任务代码。期约的主要功能是为异步代码提供了清晰的抽象。可以使用期约表示异步执行的代码块或者异步计算的值。期约具有可被序列化、连锁复合、扩展和重组的特征,在串行异步代码时非常具有价值。异步函数则是将期约应用于 JavaScript 函数的结果。它可以暂停函数执行,而不阻塞主线程的特点使得在编写基于期约的代码或组织串行、平行执行的代码方面可以非常灵活。
学习过程个人任务首先需要深刻理解期约和异步函数的概念和主要用途,这样才能够达到活用的效果。其次需要知道期约各种方法的使用,尤其是静态方法 Promise.resolve()。它在其他方法的返回值和期约连锁概念的理解方面非常重要。还有就是对异步函数的 await 的暂停和返回的理解。