用可视化的方式解释事件循环和Promise

用可视化的方式解释事件循环和Promise

本文是在原文翻译的基础上进行了部分删减和补充内容。若有问题请多多指正!
原文链接: 1.https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif 2.https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke

用可视化的方式解释事件循环

什么是事件循环?
首先让我们来了解下 JavaScript 的单线程特性:一次只能运行一个任务。单线程的程序执行到了需要异步的操作,就会需要等待。这时程序就会停下,后面的代码就不会执行,就会阻塞程序 😬

幸运的是,浏览器为我们提供了 JavaScript 引擎自身都没有的特性—— Web API 。它包含 DOM API 、setTimeout 、HTTP 请求等等,可以帮助我们创建一些异步的、非阻塞的行为。

当我们调用一个函数,它会被添加到执行栈中。执行栈是 JavaScript 引擎的一部分,并不是浏览器的特性。它是一个先进后出的栈。当一个函数返回了一个值,就是出栈。👋
1)当函数被调用的时候,函数会被压入执行栈中;当函数返回一个值的时候,会被移出执行栈。

图中的 respond 函数返回了一个 Web API 提供给我们的 setTimeout 函数。它允许我们在不打破主线程的情况下延迟执行任务。我们传递给 setTimeout 函数的回调函数 ()=> {return ‘Hey’} 被添加到了 Web API 中。setTimeout 函数和 respond 函数被挨个儿移出堆栈。

2)Web API 监听着我们传递的回调。

在 Web API 中,计时器的运行时间为 1000ms。在等待了 1000ms 之后,这个回调函数并没有被立即添加到执行栈中,而是被送到了队列中。

3)当定时计时结束时,这个回调函数被送到了任务队列中。

注意了!这意味着回调函数不是在 1000ms 后被添加到执行栈中。它只是在 1000ms 后被添加到了任务队列中。函数得排着队,等到轮到它的时候才能被执行。

现在事件循环完成任务的时刻到来了——如果执行栈为空,当之前所有调用的函数都返回了值并被移出执行栈的时候,任务队列中的第一个元素就被加到执行栈中了。在此例中,没有其他函数被调用,这意味着当回调函数成为队列中的第一项时,执行栈为空。

4)事件循环机制监听着任务队列和执行栈。若执行栈为空,则将任务队列中的第一个元素移入到执行栈中。

事件循环的大致就是这么个流程。尝试着去计算以下例子中控制台的打印结果吧:

1
2
3
4
5
6
7
const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();

让我们在浏览器上运行这段代码看看会发生什么吧:

1.我们调用了 bar 函数,bar 返回了一个 setTimeout 函数。

2.我们传递给 setTimeout 的回调被添加到 Web API 中,setTimeout 函数和 bar 被从执行栈中弹出。

3.定时器开始计时,同时执行栈中 foo 函数被调用并打印了 First ,foo 返回了值( undefined ),baz 函数被调用,bar 的回调函数被添加到任务队列中。

4.baz 打印了 Third ,并返回值 undefined 。事件循环机制此时发现当前执行栈为空,然后将回调函数移入执行栈中。

5.回调函数打印了 Second 。

用可视化的方式解释 promise 和 async-await

当我们在写 JavaScript 脚本时,我们会遇到这么一种情况:处理依赖其他任务的任务!假设我们想要获得一幅图像,压缩它,应用过滤器并保存它 📸
我们要做的第一件事情,是获得我们想要去编辑的那张图片。一个getImage函数可以做到。只有当该图像成功加载后,我们才能将该值传递给resizeImage函数。当图像大小成功调整后,我们希望在applyFilter函数中对图像应用一个过滤器。在图像被压缩并且已经添加了一个过滤器之后,我们想要保存图像并让用户知道一切工作正常!🥳 最后,我们会得到这样的结果:

em……虽然这么写没毛病,但我们会得到许多嵌套的回调函数,它们依赖于前面的回调函数。这通常被称为回调地狱——我们使用了 n 多个嵌套的回调函数。这使得代码变得难以阅读了! 幸运的是,我们可以通过Promise来解决这个问题!让我们来看看Promise是什么,以及在这种情况下它们是如何帮助我们的!😃

Promise 语法

ES6 引进了Promise。在使用 svn 时,你会看到这样的话:

“ promise 是一个值的占位符,它将在未来的某个时刻 resolve 或者 reject 。”

这个解释对于我们来说没那么清晰,那么让我们来看看 Promise 到底是个啥吧! 我们使用接收一个回调函数的Promise构造函数来创建一个 promise 。

哇哦,看看这返回了啥? Promise是一个包含状态([[PromiseStatus]])和一个值([[PromiseValue]])的对象。在上例中,你能看到[[PromiseStatus]]的值是一个"pending",而值是undefined。 不要担心,你永远都不需要关心该对象,甚至不能访问[[PromiseStatus]][[PromiseValue]]属性。不管怎样,这两个是使用 promise 时十分重要的属性。 PromiseStatus的值可能是以下三种值:

  1. fullfilled:promise 已经被解析。一切正常,promise 内部没有发生任何错误 🥳
  2. rejected:promise 已经被拒绝。呃,也就是遇到了些问题。
  3. pending:promise 没有解析也没有拒绝,promise 处于一个等待状态。
    那么 promise 啥时候是fullfilledrejectedpending呢?为什么这些状态很重要呢?
    在上例中,我们只要将简单的回调函数() => {}传递给Promise的构造函数。然而这个回调函数实际上收到了两个参数:第一个参数通常叫做resolveres(即下文提到的解析),它是Promise应该解析的时候调用的函数。第二个参数的值通常叫做rejectrej(即下文提到的拒绝),它是Promise应该拒绝的时候(有哪些地方出错时)调用的函数。 让我们来看看当我们调用resolvereject函数会打印出什么吧~在我的例子中,我调用了解析函数res和拒绝函数rej: 从上图看来,如果我们调用resolve函数,promise 的状态就是fulfilled。如果我们调用rejected函数,promise 的状态就是rejected
    promise 的值,即[[PromiseValue]]的值,是resolvedrejected函数的传参。
    有趣的是,我让 Jake Archibald 校对了这篇文章,他指出 Chrome 浏览器有个 bug ,当前显示的状态是resolved而不是fullfilled。🥳🕺

译者插一嘴:这篇文章也许历史有些久远了,截至 2020.9.19 在谷歌的控制台打印结果为
proto: Promise
[[PromiseState]]: “fulfilled”
[[PromiseResult]]: 123

现在我们知道如何更好地控制这个Promise对象了。但它是用来干什么的呢?
在介绍部分中,我展示了一个例子,在该例子中,我们获得一个图像,对其进行压缩,应用一个文件处理程序并保存它!最终,形成了一个嵌套的回调乱象。
幸运的是,Promise 能帮助我们解决这个问题。首先,让我们重写整个代码块,以便每个函数返回一个Promise
如果图像已经加载,并且一切正常,那么让我们用已加载的图像来解析promise!否则,如果在加载文件时某处出现错误,我们将拒绝包含所发生错误的promise

让我们来看看运行它之后,终端返回了什么吧:

太酷了!正如我们所期望的那样,promise获得了已解析数据的值。
我们不关心整个 promise 对象,我们只需要关心 data 的值。有一些内置的方法可以获取 promise 的值,对于一个 promise ,我们可以附上三种方法:

  • .then():在 promise 解析后调用。
  • .catch():在 promise 拒绝后调用。
  • .finally():无论 promise 是解析了还是拒绝了,通常情况下都会调用。
  • Promise.race():传入一个元素为 Promise 实例的数组,谁先解析了谁先调用.then 里的回调函数。
  • Promise.all():传入一个元素为 Promise 实例的数组,数组内的 Promise 实例全部先解析才调用.then 里的回调函数。

.then方法通过resolve方法收到数值。

.catch方法通过rejected方法收到数值。

Promise.race()的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const promise1 = new Promise((resolve, rejected) => {
setTimeout(() => {
console.log("promise1");
resolve("a");
}, 1000);
});
const promise2 = new Promise((resolve, rejected) => {
setTimeout(() => {
console.log("promise2");
resolve();
}, 1500);
});
const promise3 = new Promise((resolve, rejected) => {
setTimeout(() => {
console.log("promise3");
resolve();
}, 2000);
});
Promise.race([promise1, promise2, promise3]).then(() => {
console.log(0);
});

打印结果:

promise1

0

promise2

promise3

Promise.all()的使用方法:

1
2
3
4
// ...
Promise.all([promise1, promise2, promise3]).then(() => {
console.log(1);
});

打印结果:

promise1

promise2

promise3

0


当我们知道一个 promise 通常会 resolve 或 reject ,你可以写成Promise.resolvePromise.reject

你将在以下示例中看到这种写法 😄
getImage的例子中,我们最终不得不嵌套多个回调才能运行它们。幸运的是,then可以帮助我们改变这个局面!🥳
then的结果本身就是 promise 的值。这意味着我们可以根据需要链接任意多的.then:上一个 then 回调的结果将作为参数传递给下一个 then 回调!

真棒!这个语法已经比嵌套的回调看起来好多了。


宏任务和微任务

好了,我们知道了如何创建 promise 以及如何从 promise 中提取值。让我们添加更多的代码到脚本,并再次运行:

等等,发生了什么?!🤯 先是Start!被打印出来了。我们可以看到console.log('Start!')在第一行。第二个被打印的值却是End!,而不是 promise 解析后的值!只有在End!打印出来后,promise 的值才被打印出来。这其中发生了什么呢?
我们终于看到了 promise 的真正力量!🚀 虽然 JavaScript 是单线程的,我们能通过Promise添加异步的行为!
实际上在事件循环机制中有两种队列:宏队列(或称作任务队列)和微队列。宏任务队列用于宏任务,微任务队列用于微任务。
那么什么是宏任务什么是微任务呢?最常见有以下几种:

啊,我们在微任务列表中看到了Promise!😃 当一个Promise 解析后调用它的then(),catch()finally()方法,该方法中的回调将被添加到微任务队列中!这意味着then()catch()finally()方法中的回调不会立即执行,这实际上是在 JavaScript 代码中添加了一些异步行为! 那么什么时候执行then()catch()finally()的回调呢?事件循环给了任务不同的优先级:

  1. 当前在调用堆栈中的所有函数将被执行。当它们返回一个值时,就会从堆栈中弹出。
  2. 当调用堆栈为空时,所有排队的微任务将一个接一个地弹出到调用堆栈并执行!(微任务自身也可以返回新的微任务,有效地创造一个无限微任务循环 😬)
  3. 如果调用堆栈和微任务队列都为空,则事件循环检查宏任务队列上是否还有任务。任务被弹出到调用堆栈上,执行,然后弹出!
    让我们看一个简单的例子:
  • Task1:立即被添加到调用堆栈的函数,例如在我们的代码中立即调用它。

  • Task2, Task3, Task4:微任务,比如说一个 promise 的then中的回调函数,或一个被添加到微任务队列中的任务。

  • Task5, Task6:一个宏任务,例如setTimeoutsetImmediate的回调

    首先,Task1 返回一个值并从调用堆栈中弹出。然后,引擎检查微任务队列中排队的任务。一旦所有任务都被放到调用堆栈中并弹出,引擎就会检查宏任务队列上的任务,这些任务会弹出到调用堆栈中,并在它们返回了值时弹出。

在这段代码中,我们有宏任务setTimeout和微任务 promise 的 then()回调。一旦引擎到达setTimeout函数那行,让我们一步一步地运行这段代码,看看记录了什么!


在下面的示例中,我将展示被添加到调用堆栈的方法,例如console.logsetTimeoutPromise等。它们是内部方法,实际上不会出现在堆栈跟踪中——所以如果你正在使用调试器而在任何地方都看不到它们,请不要担心!它只是方便在没有添加很多示例代码的情况下更容易地解释这个概念 🙂
在第一行中,引擎遇到了console.log()。它被添加到调用堆栈中,然后打印值’Start!’。方法从调用堆栈中弹出,引擎继续运行。

这时引擎遇到了被调用堆栈中弹出的setTimeout方法。setTimeout方法是浏览器的原生方法:它的回调函数(() => console.log('In timeout'))将被添加到 Web API 中,直到计时器计时完成。尽管我们为定时器提供了为 0 的值,回调仍然首先被推到 Web API ,之后它被添加到宏任务队列:setTimeout是一个宏任务!

接着引擎遇到了Promise.resolve()方法。Promise.resolve()方法被添加到调用堆栈中,之后解析为值’Promise!’。然后它的回调函数then被添加到微任务队列中。

注意:.then 或者 .catch 的参数期望是函数,传入非函数则会发生值穿透。

1
Promise.resolve(1).then(2).then(Promise.resolve(3)).then(console.log); // 1

引擎接着又遇到了console.log()方法。它会立即被添加到调用堆栈中,然后打印’End!’到控制台,弹出调用堆栈,引擎继续运行。

现在引擎看到调用堆栈是空的。由于调用堆栈为空,它将检查微任务队列中是否有排队的任务!是的,有,promise then回调正在排队中!它被弹出到调用堆栈上,然后打印 promise 的解析值:’Promise !’。

引擎看到调用堆栈是空的,因此它将再次检查微任务队列,以查看任务是否已进入该队列。哦不,微任务队列都是空的。
是时候检查宏任务队列了:setTimeout回调仍然在那里等待!setTimeout的回调被弹出到调用堆栈。回调函数返回console.log方法,该方法打印了’In timeout!’,接着setTimeout回调被弹出到调用堆栈。

结束了!🥳 好像我们之前看到的输出也没那么意外嘛。

Async/Await

ES7 引入了一种在 JavaScript 中添加异步行为的新方法,能更容易地使用 promise !通过引入 async 和 await 关键字,我们可以创建隐式返回 promise 的异步函数。我们怎么做呢?
在前文中,我们看到可以使用Promise对象显式地创建Promise,无论是通过键入new Promise(() =>{}),还是Promise.resolve,或者Promise.reject
我们现在可以创建隐式返回对象的异步函数,而不是显式地使用Promise对象!这意味着我们不再需要自己编写任何Promise对象了。

虽然异步函数隐式返回 promise 这一事实非常棒,但异步函数的真正强大之处可以在使用await关键字时体现出来!通过await关键字,我们可以在await等待的值返回已解析的 promise 时暂停异步函数。如果我们想要得到这个已解析的 promise 的值,就像我们之前对then()回调所做的那样,我们可以为等待的 promise 值分配变量!
让我们看看当我们运行下面的代码块时会发生什么吧:

这其中发生了什么呢?

首先,引擎遇到一个console.log。它被弹出到调用堆栈,之后打印了Before function!

然后,调用异步函数myFunc(),之后运行myFunc的函数体。在函数体的第一行中,我们调用了另一个console.log,随后打印了In function!将console.log添加到调用堆栈,打印了值后弹出。

函数体继续被执行,这就到了第二行。最后,我们看到一个await关键字!🎉
首先,等待的值被执行:在此例中是函数one。它被弹出到调用堆栈内,并最终返回一个已解析的 promise 。 promise 被解析并且one函数返回了值之后,引擎这时遇到了await关键字。
当遇到await关键字时,async函数将被挂起。✋🏼 函数体就暂停执行了,剩下的异步函数被运行在微任务栈中!

现在异步函数myFunc在遇到await关键字时被挂起,引擎跳出了异步函数并在该函数被调用的执行上下文中继续执行代码:在本例中是全局的执行上下文中!🏃🏽‍♀️

最后在全局执行上下文中,没有需要运行的任何任务了!接着事件循环检查是否有微任务还在排队:确实有!在解析了one.myFunc的值并弹回调用栈之后,异步myFunc函数还在排队。myFunc 被弹出到调用堆栈上,并在先前停止的地方继续运行。
变量res最终获得它的值,即one函数返回的已解析 promise 的值!我们用console.log打印出res的值:’One!’。’One!’被打印到到控制台中并弹出调用堆栈!😊

这就完事了!你注意到async函数与 promise 的then的不同之处了吗?await关键字将挂起async函数,而当我们使用 then 时,Promise体将继续执行!

一些额外的细节

1)async promise 谁先执行?

1
2
3
4
async function async1() {
await async2();
console.log(" async1 end ");
}

在 Chrome 73 之前,遇到 await 会被解析成这样:

1
2
3
4
5
6
7
8
9
async function async1() {
return new Promise((resolve) => {
Promise.resolve().then(() => {
async2().then(resolve);
});
}).then(() => {
console.log("async1 end ");
});
}

在 Chrome 73 之后,遇到 await 会被解析成这样:

1
2
3
4
5
async function async1() {
async2().then(() => {
console.log("async1 end ");
});
}

所以浏览器的版本可能会影响 promise 和 async-await 的执行结果!

2)遇到不确定什么时候能 return 的情况下,该任务会在 Web API 中被挂起,先往下执行任务,直到有返回值了才将 then 中的回调函数放入微任务队列中。

1
2
3
4
5
6
axios.get("http://192.168.0.183:8200/fe/menu").then(() => {
console.log(9);
});
setTimeout(() => {
console.log(0);
}, 0);
作者

老吕

发布于

2020-12-13

更新于

2023-08-05

许可协议

CC BY-NC-SA 4.0

评论