JavaScript Visualized: Promises & Async/Await

1,850 阅读12分钟

Introduction

当我们开发JavaScript应用时候,我们经常要处理依赖于其他任务的任务!比方说,我们想要先获取一个图像,然后经过压缩,应用过滤器,最后保存它。

最后我们可能会得到这样一个代码。

上面的代码我们应该都很熟悉,俗称回调地狱,这样的代码维护性可想而知。

幸运的是我们可以通过Promise来解决上述问题,接下来我们看看Promise是什么?以及它是如何解决上述问题的。

Promise Syntax

ES6中有介绍Promise,在很多教程,你可能也会遇到这样的描述:

"A promise is a placeholder for a value that can either resolve or reject at some time in the future"

事实上,上述的解释并没有让我对Promise有更加清晰的认识,反而让我觉得它比较深不可测。因此接下来,让我们看看Promise到底是什么。

接下来让我们创建一个Promise,Promise构造器接受一个callback作为参数,OK,我们试试这样输入:

如上图,我们可以看看它返回了什么。

一个Promise实例包括一个status[[PromiseStatus]],以及一个value[[PromiseValue]]。在上述这个示例你可以看到[[PromiseStatus]]是pending,[[PromiseValue]]是undefined。

别担心 - 你永远不会有与该对象直接交互,你甚至不能访问[[PromiseStatus]][[PromiseValue]]属性!然而,当Promise工作时,这些属性的值是非常重要的。

PromiseStatus的值是一个状态机,它可以是下面三种值之一。

  • fulfilled: 表示这个promise已经被resolved,一切正常,在这个promise内没有异常发生。
  • rejected: 表示这个promise已经被rejected,哎呀有异常发生了。
  • pending: 表示当这个promise既没有被resolved也没有被rejected,那么它就一直是pending

好吧,这一切听起来不错,但是一个Promised的状态什么时候是pendingresolvedrejected?另外状态之间有什么关联?

在上面示例中,我们只是简单的传递了一个回调函数给Promise的构造器,但是实际上这个回调函数接受两个参数,第一个参数我们称为resolve或者简称res,这个方法是当这个promise应该被resolve时候调用,第二个参数我们称为reject或者简称rej,这个方法是当这个promise应该被reject时候调用,意味着程序出错了。

OK,让我们再写一个示例,这次我们传入resolvereject

不错,我们现在知道怎么去改变默认的status值pending,value值undefined。如果我们调用resolve方法那么status就会变为fulfilled,同理我们调用reject方法那么status变为rejected

相应的一个promise[[PromiseValue]]的值value就是我们调用resolve或者reject方法时候传递的参数。

有趣的是,我让Jake Archibald校对这篇文章时,他实际上指出,在Chrome浏览器目前的状态显示为resolved,而不是fulfilled的错误。

好了,那么现在我们知道如何更好的控制Promise对象了,但是它实际上有什么作用呢?

在之前我们讲述了一个关于对图像处理的代码示例,最终得到的是一个回调地狱般的xx代码。

幸运的是Promise可以帮助我们解决上述问题,首先我们重构上述代码,让每个函数都返回一个Promise。

如果图像加载一切正常,那么我们就resolve这个promise,如果在加载文件时发生错误,那么我们就reject它。

接下来我们在终端执行上述代码看看会发生什么?

Cool! promise像我们所预期的那样正常返回了图像相关的解析数据。

但是接下来怎么办呢? 我们并不关心这个promise对象,我们只关心如何去获取这个data数据,幸运的是,promise有内置的方法来获取一个promise的value。对于一个promise,我们可以执行这3种方法:

  • .then():当一个promise执行resolve方法后会调用
  • .catch(): 当一个promise执行reject方法后会调用
  • .finally: 无论一个promise是被resolve或者reject后都会调用

.then方法会接受到一个value,这个value就是我们执行resolve方法时候的参数。

相应的.catch方法也会接受到一个value,这个value就是我们执行reject方法时候的参数。

最后我们得到了这个promise对象的value,那么我们就可以做任何我们想做的处理。

仅供参考,如果你知道一个promise始终要么是resolve或者reject,那么其实我们可以直接使用Promise.resolve或者Promise.reject方法,并且传入我们想要传入的值。

也许你经常会看到下面这个示例的代码。

在上面getImage的示例中,Promise的then方法帮助我们解决了回调地狱的麻烦。

.then()本身执行的结果也是一个promise,因此它是支持链式调用的。前一个then方法执行的结果会作为下一个then方法的参数传入。

因此在getImage示例中,我们可以链式调用多个then方法,把处理过的image对象传入到下一个回调。这样我们就彻底甩脱了回调地狱,得到一个整洁的链式回调。

完美!这个语法看起来在某种程度上已经比嵌套回调好多了。

Microtasks and (Macro)tasks

现在我们知道如果去创建一个promise、以及如果提取promise中的值,那么接下来我们继续添加一些代码示例,然后运行它。

Wait what?! 🤯

首先我们可以看到打印出Start!,接下来打印出的却是End!而不是promise中的value。最后打印的是Promise!,这里面究竟发生了什么?

我们终于认识到promise的真正能量! 🚀虽然JavaScript是单线程的,但是我们可以用promise实现异步行为!

别急,我们之前不是看到过异步吗? 🤔在JavaScript事件循环中,我们不是也可以使用原生浏览器的方法,如setTimeout来实现某种异步行为?

是的!然而,事件循环中,实际上有两种类型的队列:在(宏)任务队列(macro)task queue(或者叫任务队列),以及微任务队列microtask queue。该(宏)任务队列是(宏)任务和microtask队列是microtasks。

那么什么是宏任务队列,什么是微任务队列?虽然实际上存在的比我下面列出来的多,但是在下面的表格中都是我们最常见的!

我们看到promise属于微任务队列,当一个promise执行resolve方法后,然后调用它的then()catch()finally()方法,在这些方法中的回调都将被添加到microtask queue。这也意味着thencatchfinally方法内的回调不会马上执行,本质上对于我们的javascript代码来说增加了异步的行为。

所以, thencatchfinally回调什么时候执行?事件循环对于这些任务给出了不同的优先级。

  1. 所有函数都是在当前调用栈执行,当它们返回一个值时候,就会从调用栈弹出。
  2. 当调用堆栈是空的时候,所有排队的microtask queue会依次入栈进入到调用栈,并得到执行。(Microtasks本身也可以返回新microtasks,有效地创建一个无限循环microtasks)
  3. 如果调用堆栈和microtask queue都为空,事件循环会检查(macro)task queue是否有未执行任务。如果存在,那么这些任务依次被弹出到调用堆栈,执行、最后弹出!

让我们写一个简单示例来验证下:

  • Task1: 我们常见的同步代码,被添加到调用堆栈,马上被执行然后弹出。
  • Task2, Task3, Task4: microtasks, 比如像promise的then方法回调, 或者其他添加到microtasks的任务。
  • Task5, Task6: 一个 (macro)task队列, 比如像一个setTimeout or setImmediate回调函数。

首先Task1执行完毕后返回一个值,然后从调用堆栈弹出。然后事件循环会去检查microtasks中排队的队列,然后按照顺序依次将microtasks中任务出队,弹入到调用堆栈,执行,弹出,直到清空microtasks。然后事件循环会去检查macrotasks队列是否为空,不为空,依次将它们入栈到调用堆栈、执行完后弹出。

接下来我们跑一些实际的代码论证下。

在这段代码中,我们macrotasks的setTimeout和microtasks的promise then回调。一旦事件循环执行到setTimeout函数的时候。让我们一步一步运行这段代码,看打印的内容是什么!

仅供参考 - 在下面的例子我通过将像类似console.log方法,setTimeoutPromise.resolve方法添加到调用堆栈。他们都是内部方法,实际上不会出现在stack trace中, - 所以不要担心,如果你使用调试器,你在任何地方都看不到他们!它只是辅助我们更容易理解事件循环概念🙂

在第一行,事件循环执行到console.log()方法,它将被添加到调用堆栈,之后执行打印出Start!到控制台。然后该方法从调用堆栈弹出,事件循环继续执行。

接下来事件循环执行到setTimeout方法,setTimeout被弹入到调用堆栈。setTimeout方法原产于浏览器:它的回调函数() => console.log('In timeout')将被添加到Web API,直到计时器完成。虽然我们的计时器提供的时间间隔值是0,但是这个回调仍然马上被推到Web API的第一位,之后它被添加到macrotasks queue,这是因为setTimeout是一个macro task!

接下来事件循环执行到Promise.resolve()方法,当Promise.resolve()方法添加到调用堆栈执行完毕后,返回一个值Promise!, 因此同时它的回调函数then()方法被添加到microtask queue.

接下来事件循环执行到console.log()方法,它被马上推入调用堆栈,执行,返回值End!并打印在控制台,并从调用堆栈弹出。 事件循环继续往下执行.

此时,事件循环【或者说JS引擎】发现调用堆栈为空,它会检查是否有在microtask队列中排队的任务!结果发现确实有,promise的then回调在等待执行!于是它被弹出到调用堆栈后,由于它会记录promise之前resolve()中的值,因此打印出Promise!在控制台并且从调用堆栈弹出。

JS引擎看到调用堆栈是空的,所以它会再次检查microtask队列,查看是否还有任务在进行排队。发现没有,microtask队列也是是空的。

于是JS引擎会去检查macrotask queue,发现setTimeout callback仍然在等待执行! 因此setTimeout callback被弹出进入调用堆栈,执行结束,返回一个值In timeout!并且打印到控制台,最后setTimeout callback从调用堆栈弹出。

最终, 所有的执行结束! 🥳

Async/Await

ES7在JavaScript中引入了一个新的方法来添加异步行为,并且它让promise使用起来更加容易了!我们通过引入asyncawait关键词,我们可以创建一个async函数,这个函数会隐式返回一个promise。但是...我们接下来该怎么办呢? 😮

此前, 可以看到我们可以使用Promise对象明确的创建一个promise,比如可以通过new Promise(() => {}), Promise.resolve, 或者 Promise.reject

然而现在呢我们可以通过async函数就可以隐式返回一个promise对象,这也意味着我们再也不需要手动写一个Promise了。

尽管事实上async函数隐式返回一个promise对象是非常伟大的功能,但是真正意义上是await关键字让async发挥了作用。通过await关键字我们可以暂停一个异步函数,我们可以分配一个变量给await resolved状态的promise,就像之前我们使用promise.then方法回调那样,我们就可以得到一个resoled状态的promise的值。

让我们看看当我们运行下面的代码块会发生什么:

嗯..这里发生了什么?

首先,JS引擎执行到console.log。它被弹出到调用堆栈,然后执行,打印结果Before function!到控制台,弹出调用堆栈。

然后,我们调用异步函数myFunc()myFunc()推入调用堆栈,执行该函数函数体。在函数体中的第一行,我们调用另一个的console.logconsole.log被添加到调用堆栈,执行它,并且返回值In function!打印到控制台,并从调用堆栈弹出。

myFunc()的其他函数体继续执行,当执行到第二行时候. 终于, 我们看到await关键字! 🎉

接着执行到one函数,它被推入调用堆栈,执行并且返回一个resolved promsie,一旦promsie的状态变为resolved,one函数返回一个value,然后one函数弹出调用对象,引擎遇到了await

当遇到一个await关键字,异步函数被暂停。 ✋🏼函数体的执行被暂停,而异步函数的其余部分将被以microtask的方式运行而不是一个常规的任务。

由于await关键字使得async函数 myFunc被挂起,JS引擎跳出异步函数,回到全局作用域上下文继续执行代码。于是执行console.log(),打印结果,弹出调用堆栈。

最后,没有其他任务在全局执行上下文中运行!事件循环继续检查,看看是否有任务在microtasks中排队:结果发现有异步 函数myFunc。 于是myFunc弹入调用堆栈,执行,打印结果one到控制台,弹出堆栈。

Finally, all done!

PS: 翻译有误地方请斧正。

奉上原文链接⭐️🎀 JavaScript Visualized: Promises & Async/Await

有兴趣可以关注我的公众号:定期分享有意思的文章。