据说这是大佬总结出来的事件循环

392 阅读6分钟

创作的起因

最近在构建自己的前端知识体系(距大佬说这是一个优秀的习惯...),学到了JS的结构化知识时,发现对事件循环这一块有了新的理解,希望把这段稍纵即逝的理解以文字的形式记录下来,以及从简单到复杂例子的分析过程,也是对自己知识的一个复盘。


同步与异步

我们都知道javascript自诞生以来就是一门单线程的非阻塞的脚本语言,这是由其最初的用途来决定的:与浏览器交互,是一种处理事件的设计模式(试想一下 如果javascript是多线程的,那么当两个线程同时对dom进行一项操作,例如一个向其添加事件,而另一个删除了这个dom,此时该如何处理呢?)

我们来看看维基百科针对事件循环是如何定义的

In computer science, the event loop is a programming construct or design pattern that waits for and dispatches events or messages in a program. The event loop works by making a request to some internal or external "event provider" (that generally blocks the request until an event has arrived), then calls the relevant event handler ("dispatches the event"). The event loop is also sometimes referred to as the message dispatcher, message loop, message pump, or run loop.

可以了解到事件循环其实是一种等待或者分派程序中事件或者消息的编程构造或设计模式。可以设想js的单线程就像超市中要排队买单,需要一个一个结账才能出去,如果一个人因为东西多结账慢,后面的人也必须要等着。那么问题来了,假如我们想浏览网页,需要加载一张很大的图片,难道我们的网页要一直卡着直到图片完全显示出来?W3C和Web Hypertext Application Technology Working Group定义了Web Worker,为Web内容在后台线程中运行脚本提供了一种简单的方法,线程可以执行任务而不干扰用户界面,也就是我们常说的'异步'。至此我们可以将JS的任务分为两类:

  • 同步任务
  • 异步任务

同步任务

看一个描述同步任务的图片:

从图片可知,一个方法执行会向执行栈中加入这个方法的执行环境,在这个执行环境中还可以调用其他方法,甚至是自己,其结果不过是在执行栈中再添加一个执行环境。这个过程可以是无限进行下去的,除非发生了栈溢出,即超过了所能使用内存的最大值。

异步任务

与同步任务不同的是,异步任务非阻塞,如下图所示:

异步任务是如何做到不阻塞的呢?涉及到一个概念事件队列(敲黑板!这个概念接下来会多次用到)

js引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。

那么这个时候会有一个疑问,我们都知道异步任务彼此并不相同,那么他们的执行顺序是如何决定的呢?

宏任务与微任务

  • 宏任务(macro task): 每次栈执行的代码就可以称之为一个宏任务,下列皆可构成宏任务:
        script(整体代码)
        setTimeout
        setInterval
        I/O
        UI交互事件
        postMessage
        MessageChannel
        setImmediate(Node.js 环境)
    
  • 微任务(micro task): 当前task执行结束后立即执行的任务,下列为微任务:
        Promise.then
        Object.observe
        MutaionObserver
        process.nextTick(Node.js 环境)
    

执行顺序构成的事件循环过程

被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码...,每次执行完毕浏览器都会重新渲染一次。如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”的原因。

至此我们可以这样理解,一个宏任务内包含多条微任务,每一个微任务的执行顺序都是根据其入栈的时间来决定。

一起来看一个简单的例子(简单且完整):

function foo() {
    setTimeout(() => {
    console.log("1");
  }, 0);

new Promise((resolve) => resolve()).then(() => console.log("2"));
console.log("3");
}
foo();

分析一下这个例子的完整流程(接下来不会有这么完整的了!):

  1. foo执行开启一段宏任务
  2. 检测到setTimeout,是宏任务,所以是下一个事件队列的开始,直接往后站,继续向下寻找是否有当前宏任务可执行的微任务
  3. 检测到Promise发现有一个异步的then返回,放入回调队列,继续向下寻找
  4. 检测到微任务console同步代码,直接执行,打印'3'
  5. 没有同步代码可执行,回到回调队列执行所有进入队列的任务,发现Promise.then可以执行,打印'2'
  6. 回调队列执行完毕,到下一条宏任务,发现setTimeout早早的就在等待,打印'1'

可以发现打印中间会有一个undefined, 我们可以简单的理解为是每一段宏任务的分割

用一张图可以清晰的表达他们的执行顺序:

看到这里,我可以很负责任的说,再也没有理论知识了(手动撒花!)

不过我们掌握知识,是为了在技术路上更好的成长(面试的时候能吹得轻松自如),所以最后再举两个复杂点的案例,尝试分析一下

例子1

奔着负责任的态度,我还是画一下他们的宏任务和微任务

async function foo() {
  console.log("-1");

  await new Promise((resolve) => resolve());
  console.log("-2");
}

new Promise((resolve) => resolve()).then(() => {
    console.log("2"),
      new Promise((resolve) => resolve()).then(() => console.log("3"));
  })

setTimeout(() => {
  console.log("7");
}, 100);

console.log("6");
foo();

执行结果为

6 -1 2 -2 3 7

是不是感觉已经掌握了呢

例子2

async function foo() {
  console.log("-1");

  await new Promise((resolve) => resolve());
  console.log("-2");
}

new Promise((resolve) => (console.log("1"), resolve())).then(() =>
  new Promise((resolve) => resolve()).then(() => {
    console.log("2"),
      new Promise((resolve) => resolve()).then(() => console.log("3"));
  })
);

setTimeout(() => {
  console.log("7");
}, 100);

setTimeout(() => {
  console.log("4");

  setTimeout(() => {
    console.log("8");
  }, 100);

  new Promise((resolve) => resolve()).then(() => console.log("5"));
}, 0);

console.log("6");
foo();

整体过程简化,相信你们应该也会看得懂的!

有一个地方需要解释的是, promise中有一个地方为逗号表达式 (console.log("1"), resolve())), 逗号表达式是从左往右依次执行,运算优先级为最低

结尾

引用大佬的一句名言,共勉

“外在转变过程指的是,建立起一个有潜力或有能力的好名声,这能够在很大程度上改变我们的自我认知; 而内在转变过程涉及内在动机和自我定位的转变,这种转变并不是独立发生的,而是在与他人所建立的关系中渐渐发生的转变。” ——《能力陷阱》