异步编程
异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。
同步 vs 异步
同步:按顺序执行指令
异步:类似于系统中断,即当前进程外部的实体可以触发代码执行
期约
期约是对尚不存在结果的一个替身
期约基础
ECMAScript 6新增的引用类型Promise,可以通过new操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数,如果不提供执行器函数,就会抛出SyntaxError。
1. 状态机
期约是一个有状态的对象
期约状态落定后 不可逆。
期约的状态是私有的,不能直接通过JavaScript检测到,也不能被外部JavaScript代码修改(期约故意将异步行为封装起来,从而隔离外部的同步代码)。
2. 解决值、拒绝理由
每个期约只要状态切换为兑现,就会有一个私有的内部值(value)。类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由(reason)。无论是值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为undefined。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。
new Promise((resolve,reject)=>{
console.log('start ~~~~~~~~~~>>');
resolve('start')
}).then(res=>{
console.log('res ~~~~~~~~~~>>>',res)
}).catch(err=>{
console.log(err)
})
3. 通过执行函数控制期约状态
由于期约的状态是私有的,所以只能在内部进行操作,内部操作在期约的执行器函数中完成。
执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。 控制期约状态的转换是通过调用它的两个函数参数实现的。 这两个函数参数通常都命名为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());
// Uncaught error (in promise)
setTimeout(console.log, 0, p2);
//Promise<rejected>
在初始化期约时,执行器函数已经改变了每个期约的状态。执行器函数是同步执行的。
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<resolved>
为避免期约卡在待定状态,可以添加一个定时退出功能
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>
因为期约的状态只能改变一次,所以这里的超时拒绝逻辑中可以放心地设置让期约处于待定状态的最长时间。如果执行器中的代码在超时之前已经解决或拒绝,那么超时回调再尝试拒绝也会静默失败。
4. Promise.resolve()
期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。
通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。
let p1 = new Promise((resolve, reject) => resolve());
let p2 = Promise.resolve();
这个解决的期约的值对应着传给Promise.resolve()的第一个参数
使用这个静态方法,实际上可以把任何值都转换为一个期约
对这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似于一个空包装。因此,Promise.resolve()可以说是一个幂等方法
这个幂等性会保留传入期约的状态
这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约
因此,也可能导致不符合预期的行为
5. Promise.reject()
Promise.reject()会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过try/catch捕获,而只能通过拒绝处理程序捕获
这个拒绝的期约的理由就是传给Promise.reject()的第一个参数。 这个参数也会传给后续的拒绝处理程序。
Promise.reject()没有照搬Promise.resolve()的幂等逻辑
如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由。
6. 同步/异步执行的二元性
拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,try/catch块并不能捕获该错误。
期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步执行模式的媒介。
代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法。
期约的实例方法
1. 实现thenable接口
在ECMAScript暴露的异步结构中,任何对象都有一个then()方法。
class MyThenable { then() {} }
2. Promise.prototype.then()
Promise.prototype.then()是为期约实例添加处理程序的主要方法。
这个then()方法接收最多两个参数:onResolved处理程序和onRejected处理程序。 如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。
期约只能转换为最终状态一次,所以这两个操作一定是互斥的。
传给then()的任何非函数类型的参数都会被静默忽略。
如果想只提供onRejected参数,那就要在onResolved参数的位置上传入undefined/null。这样有助于避免在内存中创建多余的对象。
Promise.prototype.then()方法返回一个新的期约实例
这个新期约实例基于onResovled处理程序的返回值构建。处理程序的返回值会通过Promise.resolve()包装来生成新期约。如果没有提供这个处理程序,则Promise.resolve()就会包装上一个期约解决之后的值。如果没有显式的返回语句,则Promise.resolve()会包装默认的返回值undefined。
如果有显式的返回值,则Promise.resolve()会包装这个值。
抛出异常会返回拒绝的期约。
返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的期约中。
onRejected处理程序也与之类似:onRejected处理程序返回的值也会被Promise.resolve()包装。onRejected处理程序的任务是捕获异步错误。拒绝处理程序在捕获错误后不抛出异常是符合期约的行为,应该返回一个解决期约。
用Promise.reject()替代之前例子中的Promise.resolve()之后的结果:
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处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。onFinally处理程序不知道期约状态是解决还是拒绝,所以这个方法主要用于添加清理代码,避免冗余代码。
Promise.prototype.finally()方法返回一个新的期约实例。
这个新期约实例不同于then()或catch()方式返回的实例。因为onFinally被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。
如果返回的是一个待定的期约,或者onFinally处理程序抛出了错误(显式抛出或返回了一个拒绝期约),则会返回相应的期约(待定或拒绝)。
返回待定期约的情形并不常见,这是因为只要期约一解决,新期约仍然会原样后传初始的期约(父期约)。
5. 非重入期约方法
当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由JavaScript运行时保证,被称为“非重入”特性。
在一个解决期约上调用then()会把onResolved处理程序推进消息队列。但这个处理程序在当前线程上的同步代码执行完成前不会执行。因此,跟在then()后面的同步代码一定先于处理程序执行。
先添加处理程序后解决期约也是一样的。如果添加处理程序后,同步代码才改变期约状态,那么处理程序仍然会基于该状态变化表现出非重入特性。
即使先添加了onResolved处理程序,再同步调用resolve(),处理程序也不会进入同步线程执行
即使期约状态变化发生在添加处理程序之后,处理程序也会等到运行的消息队列让它出列时才会执行。
非重入适用于onResolved/onRejected处理程序、catch()处理程序和finally()处理程序。
6. 邻近处理程序的执行顺序
如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是then()、catch()还是finally()添加的处理程序都是如此。
7. 传递解决值和拒绝理由
到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可以进一步对这个值进行操作。在执行函数中,解决的值和拒绝的理由是分别作为resolve()和reject()的第一个参数往后传的。然后,这些值又会传给它们各自的处理程序,作为onResolved或onRejected处理程序的唯一参数。
Promise.resolve()和Promise.reject()在被调用时就会接收解决值和拒绝理由,它们返回的期约也会像执行器一样把这些值传给onResolved或onRejected处理程序。
8. 拒绝期约与拒绝错误处理
拒绝期约类似于throw()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。
期约可以以任何理由拒绝,包括undefined,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的。
注意错误的顺序:Promise.resolve().then()的错误最后才出现,这是因为它需要在运行时消息队列中添加处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。
正常情况下,在通过throw()关键字抛出错误时,JavaScript运行时的错误处理机制会停止执行抛出错误之后的任何指令。
在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令。
异步错误只能通过异步的onRejected处理程序捕获。
// 正确
Promise.reject(Error('foo')).catch((e) => {});
// 不正确
try { Promise.reject(Error('foo')); } catch(e) {}
这不包括捕获执行函数中的错误,在解决或拒绝期约之前,仍然可以使用try/catch在执行函数中捕获错误。
then()和catch()的onRejected处理程序在语义上相当于try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此,onRejected处理程序的任务应该是在捕获异步错误之后返回一个解决的期约。
期约连锁与期约合成
多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。
1. 期约连锁
把期约逐个地串联起来。
每个期约实例的方法(then()、catch()和finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。
2. 期约图
因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。
// A
// / \
// B C
// /\ /\
// D E F G
日志的输出语句是对二叉树的层序遍历。如前所述,期约的处理程序是按照它们添加的顺序执行的。由于期约的处理程序是先添加到消息队列,然后才逐个执行,因此构成了层序遍历。树只是期约图的一种形式。考虑到根节点不一定唯一,且多个期约也可以组合成一个期约,所以有向非循环图是体现期约连锁可能性的最准确表达。
3. Promise.all()和Promise.race()
romise类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()和Promise.race()。而合成后期约的行为取决于内部期约的行为。
Promise.all()
Promise.all()静态方法创建的期约会在一组期约全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新期约。
合成的期约只会在每个包含的期约都解决之后才解决。
如果至少有一个包含的期约待定,则合成的期约也会待定。如果有一个包含的期约拒绝,则合成的期约也会拒绝。
如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,按照迭代器顺序返回。
如果有期约拒绝,则第一个拒绝的期约会将自己的理由作为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。合成的期约会静默处理所有包含期约的拒绝操作。
Promise.race()
Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。
这个方法接收一个可迭代对象,返回一个新期约。
Promise.race()不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,Promise.race()就会包装其解决值或拒绝理由并返回新期约。
如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。与Promise.all()类似,合成的期约会静默处理所有包含期约的拒绝操作。
4. 串行期约合成
期约的另一个主要特性:异步产生值并将其传给处理程序。
基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。
异步函数
异步函数,也称为“async/await”(语法关键字),是ES6期约模式在ECMAScript函数中的应用。async/await是ES8规范新增的。这个特性从行为和语法上都增强了JavaScript,让以同步方式写的代码能够异步执行。
异步函数
ES8的async/await旨在解决利用异步结构组织代码的问题。为此,ECMAScript对函数进行了扩展,为其增加了两个新关键字:async和await。
async
async关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上。
async function foo() {}
let bar = async function() {};
let baz = async() => {};
class Qux { async qux() {} }
使用async关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通JavaScript函数的正常行为。
异步函数如果使用return关键字返回了值(如果没有return则会返回undefined),这个值会被Promise.resolve()包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约。
直接返回一个期约对象也是一样的。
异步函数的返回值期待(但实际上并不要求)一个实现thenable接口的对象,但常规的值也可以。如果返回的是实现thenable接口的对象,则这个对象可以由提供给then()的处理程序“解包”。如果不是,则返回值就被当作已经解决的期约。
与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约。
拒绝期约的错误不会被异步函数捕获。
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(awaitp);
}
foo(); // 3
await关键字会暂停执行异步函数后面的代码,让出JavaScript运行时的执行线程。这个行为与生成器函数中的yield关键字是一样的。
await关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。
await关键字期待(但实际上并不要求)一个实现thenable接口的对象,但常规的值也可以。如果是实现thenable接口的对象,则这个对象可以由await来“解包”。如果不是,则这个值就被当作已经解决的期约。
等待会抛出错误的同步操作,会返回拒绝的期约。
单独的Promise.reject()不会被异步函数捕获,而会抛出未捕获错误。
不过,对拒绝的期约使用await则会释放(unwrap)错误值(将拒绝期约返回)
await的限制
await关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过,定义并立即调用异步函数是没问题的。
异步函数的特质不会扩展到嵌套函数。因此,await关键字也只能直接出现在异步函数的定义中。
在同步函数内部使用await会抛出SyntaxError。
停止和恢复执行
async/await中真正起作用的是await。async关键字,是一个标识符。
要完全理解await关键字,必须知道它并非只是等待一个值可用那么简单。JavaScript运行时在碰到await关键字时,会记录在哪里暂停执行。等到await右边的值可用了,JavaScript运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。
因此,即使await后面跟着一个立即可用的值,函数的其余部分也会被异步求值。
(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后面是一个期约,则问题会稍微复杂一些。此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。
(1)打印1;
(2)调用异步函数foo();
(3)(在foo()中)打印2;
(4)(在foo()中)await关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务;
(5)期约立即落定,把给await提供值的任务添加到消息队列;
(6)foo()退出;
(7)打印3;
(8)调用异步函数bar();
(9)(在bar()中)打印4;
(10)(在bar()中)await关键字暂停执行,为立即可用的值6向消息队列中添加一个任务;
(11)bar()退出;
(12)打印5;
(13)顶级线程执行完毕;
(14)JavaScript运行时从消息队列中取出解决await期约的处理程序,并将解决的值8提供给它;
(15)JavaScript运行时向消息队列中添加一个恢复执行foo()函数的任务;
(16)JavaScript运行时从消息队列中取出恢复执行bar()的任务及值6;
(17)(在bar()中)恢复执行,await取得值6;
(18)(在bar()中)打印6;
(19)(在bar()中)打印7;
(20)bar()返回;
(21)异步任务完成,JavaScript从消息队列中取出恢复执行foo()的任务及值8;(22)(在foo()中)打印8;
(23)(在foo()中)打印9;
(24)foo()返回。
异步函数策略
实现sleep()
利用平行执行
顺序等待了5个随机的超时
用一个for循环重写
可以先一次性初始化所有期约,然后再分别等待它们的结果
用数组和for循环再包装一下
虽然期约没有按照顺序执行,但await按顺序收到了每个期约的值
串行执行期约
栈追踪与内存管理
期约与异步函数的功能有相当程度的重叠,但它们在内存中的表示则差别很大。
展示了拒绝期约的栈追踪信息:
JavaScript引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。
异步函数:
栈追踪信息就准确地反映了当前的调用栈。fooPromiseExecutor()已经返回,所以它不在错误信息中。但foo()此时被挂起了,并没有退出。JavaScript运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样就不会像之前的例子那样带来额外的消耗。
总结
期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用、复合、扩展和重组。
异步函数是将期约应用于JavaScript函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代JavaScript工具箱中最重要的工具之一。