首页 JavaScript JavaScript Promise 和 Async/Await

JavaScript Promise 和 Async/Await

0 2.1K

利用 promise,我们可以以一种可控的方式编写异步代码。利用基于 promise 的 async/await 语法,我们可以以一种同步的形式编写异步代码,从而为我们节省大量时间,代码也更加可读。

JavaScript 在单一线程执行代码,这使得 JavaScript 经常阻塞。下面我们看一个简单的例子,在这个例子中,我们会顺序调用三个函数:

在上面的代码中,每个函数调用和console.log()语句以同步的方式顺序执行。这意味着,只有函数调用有一个返回值,下一行代码才能够被执行。默认情况下,如果函数没有return语句,则会返回undefined

如果使用 Web API,某些 JavaScript 任务会被移动到另外的线程。例如,处理 AJAX 请求的任务应当在另外的线程完成,否则,我们的主线程就会被阻塞,直到网络请求返回。这显然是非常糟糕的用户体验,因为用户的屏幕会被冻结几秒甚至几分钟。

Web API 是一种为了执行异步任务,扩展了 JavaScript 功能的 API(并非指带有网络请求的 API)。例如,setTimeout 是一个 Web API,它可以延迟一段时间再执行某些动作。为了理解 Web API 是如何工作的,或者仅仅为了理解setTimeout是如何工作的,我们需要了解事件循环的相关知识。

Web API 不属于 JavaScript 标准。它并不包含在 JavaScript 引擎之中。Web API 通常由浏览器,或者服务器端框架,例如 NodeJS 提供实现。

大体而言,setTimeout(callback, delay)函数接受一个callback回调函数,并且临时保存下来;在等待指定的delay毫秒之后,只要函数调用栈是空的,就把callback函数入栈。此时,回调函数就会被执行。Web API 大致以这样的过程运行。

通常,大部分 Web API 都是基于回调函数的。在异步操作完成之后,它们需要通知调用一个回调函数。下面,我们给之前的例子添加上异步操作,看看这样会有什么问题。

在上面的例子中,我们给每个函数添加了不同的延迟,然后在打印日志。因此,虽然我们以a()b()c()的顺序调用函数,但最终的输入依然是按照延迟时间来的,这是因为,具有最小的setTimeout延迟时间的会最先执行。

这就是理论上的执行顺序。第 20 行的函数a()的调用中,因为调用了setTimeout(),会把那个包含了console.log('result of a()')的回调函数进行注册,然后返回。

然后,第 21 行的console.log()语句执行。然后是函数b()以类似的方式执行,以此类推。一旦延迟时间到了,setTimeout把回调函数发送给事件循环,以便尽可能快地执行;事件循环会把这些任务以队列的形式依次执行。这个队列即所谓的任务队列主任务队列

事件循环是运行在 JavaScript 主线程中的无限的单一线程循环,用于监听不同的事件。它的任务是接受回调函数,然后在主线程执行回调函数。由于事件循环就是运行在主线程,一旦主线程忙,事件循环就会在那个时刻开始卡死。

回调函数等待执行的队列被称为任务队列。事件循环每次从队列中取出最先放入的回调函数放入主调用栈去执行,也就是先入先出策略。在主调用栈,这些排队的回调函数会同步执行。只有主调用栈是空的,或者主线程不忙的时候,事件循环才会把回调函数放入。

当所有同步函数都执行完毕之后,主调用栈才会变空。这就是为什么在上面的代码中,函数外面的console.log()语句会先执行,就是因为它们会在回调函数的console.log()语句之前先进入主调用栈。

同理,这也是为什么所有的函数外面的console.log()语句会以同步的方式一起执行,而函数里面的console.log()语句则是按照延迟时间的顺序执行。

所以,现在就有一个难办的问题,我们怎么让函数外面的console.log()语句在函数内部的语句执行完毕之后再执行呢?最终,我们想要下面的执行结果:

result of a()
a() is done!
result of b()
b() is done!
result of c()
c() is done!

答案就在回调函数上面。按照定义,回调函数是在一个任务结束的时候才被调用的函数。我们给函数a()b()c()添加一个回调函数:

在上面的例子中,我们使用了 ES6 的箭头函数语法。我们给每个包含异步操作的函数添加了一个包含console.log()语句的回调函数。

setTimeout的回调中,我们调用了通过参数传入的回调函数callback,这个回调函数会执行console.log('...done!')语句。这样,我们就能保证在异步任务结束之后,才能执行另外的任务,而这个另外的任务,就是通过回调函数作为参数传入的。

回调地狱

但是,我们并没有完全解决这个问题。我们需要的是顺序执行任务。我们可以先让a()执行完毕,然后再执行b(),最后是c()。欢迎来到回调地狱!

一个简单的思路是,在a()的回调中调用b(),因为这里是我们知道的能够保证a()任务已经执行完毕的地方;然后类似的,在b()的回调中执行c()

在上面的例子中,我们利用嵌套的回调函数实现了异步任务的顺序执行。这种回调函数的嵌套被称为回调地狱。这里我们仅有 3 层函数调用,整个代码看起来就已经很难读懂了。

利用 promise 拯救

Promise 让我们在编写复杂的异步代码时,能够稍微简单一点。promise 是一个对象,包含一个then函数和一个catch函数。当 promise 返回一个值或者出错时,这些函数就会被调用。下面,我们来介绍如何创建 promise。

Promise对象由Promise构造函数创建。Promise构造函数需要一个回调函数(被称为执行器函数 executor function)作为参数。这个回调函数接受两个函数作为参数:resolvereject,我们需要调用其中一个函数,并且使用一个可选的值作为参数。

var myPromise = new Promise( ( resolve, reject ) => {
  resolve( 'successPayload' );
  // reject( 'errorPayload' );
} );
myPromise
  .then( successCallback )
  .catch( errorCallback )
  .finally( finallyCallback );

上面的代码,我们可以看到,我们使用一个可选的接受值调用resolve函数,或者使用一个可选的拒绝值调用reject函数。这些函数可以在一个异步回调中调用,例如在setTimeout的回调里面。如果没有值传递给这些函数,那么参数值就是undefined

如果resolve被调用,then函数(被称为处理器 handler)会被调用,successCallback会被执行,同时successPayload作为其参数;如果reject被调用,catch处理器会被调用,errorCallback会被执行,同时errorPayload作为其参数;finally处理器会在 promise 稳定的时候(也就是resolvereject被调用的时候)始终被调用,该调用没有参数。

如果finally出现在第一位,那么,它会在thencatch处理器之前被调用。

我们看到,thencatchfinally函数是一种链式调用的形式,我们会在后面解释为什么会这样。下面,我们在前面的例子中引入 promise。

在上面的例子中,我们创建了promiseA,1000ms 后会执行成功。由于我们的 promise 执行完毕,then处理器首先被执行,然后finally处理器也被执行。

在上面的例子中,我们让promiseA在 1000ms 后会执行失败。此时,catch处理器首先被执行,然后finally处理器也被执行。

then中处理失败状态

处理 promise 的失败状态并不一定需要catch处理器,then处理器的第二个参数同样可以处理 promise 的失败状态。

不过,使用catch函数处理 promise 的拒绝状态是更推荐的做法。如果catchthen函数都处理了 promise 的拒绝状态,那么,catch处理器会被忽略。

promise是如何工作的?

关于 JavaScript 中 promise 的最大误解是,promise 是异步的。并不是 promise 的每一部分都是异步的。下面的例子解释了很多问题。

上面的例子中,我们的代码从开始到结束,都是以同步的形式执行的。promise 的执行器函数也是以同步的方式运行。由于我们在执行器函数中包含了setTimeout()的调用,而setTimeout()的回调函数中有resolve()的调用,因此这部分代码会在异步代码执行的之后才会被执行。

thencatch以及finally函数将其回调函数参数进行注册,在 promise 接受或拒绝的时候,这些回调函数会提供给事件循环。这些回调函数会被添加到微任务队列。微任务队列比主任务队列的优先级更高。因此,事件循环会优先执行微任务队列中的任务。

上面的代码中,所有的同步console.log语句都会被首先执行,因为它们会首先压入调用栈。然后,setTimeout()的回调在主任务队列中等待,promiseA的回调在微任务队列中等待;由于微任务队列比主任务队列具有更高的优先级,因此,事件循环会首先选择执行微任务,然后执行主任务。

Promise.resolve( data )Promise.reject( errData )

Promise类有两个static函数resolvereject,作用是返回一个Promise对象,同时立即使用值填充。例如,Promise.resolve()类似如下代码:

const fulfilledPromise = new Promise( ( resolve ) => resolve() )

值得注意的一点是,即便我们立即使用resolvereject设置了 promise 的状态,也就是说,并没有使用异步函数,处理器在执行的时候,也需要等待 promise 在主 JavaScript 执行完成之后才会被调用。这意味着,只有主调用栈为空,promise 处理器函数才会被执行。下面来看看代码。

运行时错误处理

另外一个重要的问题是,catch处理器不仅会在 promise 的拒绝状态时被调用,而且会在 JavaScript 执行执行器函数时发生异常时被调用

在上面的例子中,我们试图增加一个没有定义的变量。执行器函数会因此发生异常,promise 捕获该异常,然后自动调用catch处理器,同时,该错误信息会作为catch处理器的参数。如果运行时异常发生在执行器函数中的异步调用的回调函数时,那么,这个异常就不会被捕获。看下面的例子:

catchfinally处理器都是可选的。但是,完全忽略catch处理器并不安全。这是因为即便我们在执行器函数中使用resolve,也不能保证不会发生运行时异常。

如果我们没有对 promise 的异常注册catch处理器函数,这个错误就会直接在我们的主执行上下文抛出,可能会让整个程序崩溃。

在上面的例子中,由于我们没有给 promise 注册catch回调,在 promise 执行器函数中的异常就不会被捕获,于是整个程序崩溃了。所以,简单来说,始终要给 promise 添加catch处理器,即使没有错误发生,也应该这么做。

promise 的不同状态

我们已经知道了,在执行器函数中,promise 可以使用resolve填充,然后会调用then处理器。这此之前,promise 进入到等待 pending 状态。一旦resolve被调用,promise 会达到填充 fulfilled 状态;如果调用的是reject,则进入拒绝 rejected 状态。promise 没有提供任何 API 来检测当前状态,如果要检测,只能使用第三方的调试工具。比如 Chrome 的控制台:

使用 promise 处理器返回新的 promise

现在,我们已经了解了thencatch以及finally函数,这些都是Promise类原型的函数。这些函数都是链式的,意味着我们可以在一个上面直接调用另外一个。之所以能够这样,是因为这些函数都会返回一个新的Promise对象。

  • 如果 promise 是填充状态,那么,then返回一个新的被填充过的Promise对象,同时其数据为undefined。如果在处理器函数中包含return语句,则会返回一个填充状态的Promise对象,同时,return语句返回的数据作为该新的Promise对象的数据。
  • 如果 promise 是拒绝状态,那么,catch会返回一个新的Promise对象,其数据为undefined。如果在处理器函数中包含return语句,则会返回一个填充状态的Promise对象,同时,return语句返回的数据作为该新的Promise对象的数据。
  • finally返回一个新的Promise对象,其数据为undefined。如果在处理器函数中包含return语句,则会返回一个填充状态的Promise对象,同时,return语句返回的数据作为该新的Promise对象的数据。
  • 当 promise 被填充时,只有第一个then会被调用;当 promise 被拒绝时,也只有第一个catch被调用。在那之后,按照thencatch出现的顺序,这些处理器函数被依次调用。

我们看一个例子。

另外一个有趣的事情是,我们可以从这些处理器中返回Promise对象,而不是普通对象。这么做的结果是,我们可以直接拿到返回的Promise对象,而不是自动生成的已填充的对象。

当处理器返回Promise对象时,我们不需要单独处理每一个 promise 的拒绝状态,拒绝状态会被层层向上到父对象,直到发现catch处理器。

嵌套的 promise

现在,真正的问题是,我们怎么使用 promise 处理异步任务,来避免前面我们提到的嵌套的回调呢?最显而易见的答案是,使用嵌套的 promise 回调。

在上面的例子中,我们的函数abc在一定的延迟之后会返回填充的Promise对象。我们确保只有当一个Promise对象被创建和填充之后,另外的Promise对象才能够被创建和填充。

之前我们提到,promise 处理器函数会返回一个新的Promise对象,拒绝这个返回的Promise对象可以由父 promise 处理器处理。由此,我们可以简化上面的代码。

Promise.all()Promise.race()

Promise.all([promises])函数接受一个Promise对象数组,返回值是一个新的Promise对象。当数组中所有的Promise对象都被填充的时候,这个Promise对象才会被填充。Promise.race([promises])函数同样接受一个Promise对象数组,返回值是一个新的Promise对象。当数组中任意一个Promise对象被填充的时候,这个Promise对象才会被填充。

在实际情况中,这些函数接受的参数类似是一个任意数据类型的遍历器。

在这两个函数中,如果有任意的 promise 被拒绝,那么,函数返回的 promise 直接进入拒绝状态,其余Promise对象的结果都会被忽略。在race函数中,这些Promise对象会彼此竞争,一旦某一个 promise 完成,函数返回的 promise 的值就是完成的那个 promise 的值。

我们也可以在数组中加入非 promise 类型的数据,这些数据会通过Promise.resolve()创建一个Promise对象并进行填充。

在上面的例子中,我们让三个函数返回的Promise对象进行竞争。由于b()更快返回,所以它赢得了竞争。

利用Promise.all(),我们能够等待所有 promise 都完成之后再执行某些任务。一个常见的用例是,应用程序等待多个基于 promise 的 AJAX 请求都返回之后,再去进行接下来的任务。

Async/Await

Async/Await 是以同步代码风格编写多个 promise 的语法糖。我们可以把async关键字放在函数声明前面,该函数会返回一个Promise对象,我们可以在这个函数里面使用await关键字阻塞代码,直到里面的 promise 接受或拒绝。

async function myFunction() {
   var result = await new MyPromise();
   console.log( result );
}
myFunction(); // returns a promise

在上面的例子中,我们创建了一个函数myFunction,并且添加了async关键字。这个关键字将函数转变为异步的,意味着函数调用的时候,会返回一个 promise,代码执行会和平常一样。

我们说,在async函数中的await关键字会阻塞函数上下文中的 JavaScript 代码的执行,直到它所等待的 promise 被设置。这给我们一种类似同步代码的感觉。

我们用async/await风格重新编写一下前面的例子。

在上面的例子中,我们在async函数中返回 promise 结果的数组。这会填充async函数返回的那个Promise对象。

如果async函数中有 promise 被拒绝,则函数返回的 promise 也会被拒绝,其数据值为错误信息。如果async函数中发生异常,那么,函数返回的 promise 也会被拒绝。这一点类似于Promise执行器函数出现异常时,promise 被拒绝的情形。

在上面的例子中,b()返回的 promise 进入拒绝状态,async函数所在的线程失败,由其返回的 promise 的catch处理器进行处理。为了安全地处理 promise 的拒绝,我们应该在async函数中使用try/catch

我们还可以在async函数中返回一个新的Promise对象。

如果没有返回任何值,我们可以直接忽略async函数返回的Promise对象的处理。这种模式在今天完全回避 promise 处理器时非常常见。

async/await带来的一个主要优点是能够创建异步生成器函数。我们使用await关键字,当 promise 解决时,使用yield语句返回值。

因此,.next()函数返回一个Promise对象,我们可以使用awaitthen处理器来接受值。

使用生成器的最好的方法是for-of循环。之前,for-of循环是同步遍历,因此不能用for-of循环去遍历Promise对象数组,因为无法等待 promise 完成。但是,某些浏览器支持使用for-of循环等待 promise 完成。这可以通过使用await关键字实现。由于for-of循环可以遍历数组或者可遍历对象 iterable object(例如生成器),我们可以这样重新编写上面的代码。

async/await阻塞主线程吗?

await这个关键字的样子似乎可以猜测,await会阻塞整个线程的执行,直到它所等待的 promise 返回。但实际情况并不是这样。async/await模式依然基于经典的Promise语法。

StackOverflow 上面有一篇回答解释了async/await背后的机制。简单来说,async函数更像一种同步运行的 promise 执行器函数。await关键字更像是包含了其后所有语句的then回调。

从上面的代码,我们总结出下面的执行顺序。

  1. promiseApromiseB执行器函数同步调用
  2. getPromiseClassical函数调用添加到调用栈
  3. 由于 promise 已经完成,因此注册到promiseA上面的then回调被添加到微任务队列
  4. getPromiseClassical函数返回
  5. getPromiseAsync函数调用添加到调用栈
  6. await promiseA后面的所有语句被封装到一个伪回调函数中,被添加到微任务队列
  7. getPromiseAsync函数返回
  8. 现在,调用栈空了,事件循环把列队中第一个回调函数添加到调用栈,这个回调函数开始执行,打印出promiseClassical: A。然后,它会注册promiseB的另一个回调函数,由于promiseB已经完成了,因此这个回调函数被添加到队列。接下来,主函数返回,调用栈又空了
  9. 事件循环继续执行第二个回调函数,promiseAsync: A被打印。然后,所有的在await promiseB后面的代码被封装到一个伪回调函数中,再被添加到微任务队列。接下来,主函数返回,调用栈又空了。
  10. 微任务队列的下一个回调函数是promiseBthen处理器。它会被执行,然后打印promiseClassical: B以及另外一个打印语句。然后,函数返回,调用栈空了。
  11. 微任务队列中的最后一个函数被压入调用栈,打印出promiseAsync: B语句以及另外的日志。最终,程序执行完毕。

上面的步骤是async/await语法的一般化解释,但实际机制会更复杂。V8 JavaScript 引擎的这篇文档详细解释熬了实际执行机制。但好消息是,V8 引擎的行为已经被 ES 标准标准化了。

尽管 promise 看起来很不错,但还是有一些缺点的。例如,promise 不能被取消。一旦 promise 被创建,就不能终止。这意味着,它的处理器在某个时间点一定会被调用,不管发生了什么。

另外一个缺点是,promise 不能重新执行。一旦 promise 完成并且被处理,就不能再复用它去执行相同的任务了。

我们会在后面的文章中再介绍 TypeScript 是如何实现Promise以及async/aswit的。

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2