事件循环(Event Loop)吐血整理,弄明白输出顺序类题目

3,255 阅读11分钟

写在前面

  • 事件循环是什么?
  • 事件循环的作用是什么?
  • 事件循环是怎么循环的?

事件循环在面试、笔试中也会经常遇到,可能不会直接问时间循环是什么,但是相信大多数人都遇到过让写输出顺序的题目。

如果你和我一样,对上面三个问题也存在疑问。那么,可以继续往下读了~

首先,我们都知道,JavaScript是一门单线程语言。这是因为JavaScript的主要作用是和用户进行交互,比如渲染动画、加载数据等,如果JavaScript多线程运行,就可能会出现DOM加载不一致等问题。

但如果完全单线程运行,只有上一个任务执行完毕才能执行下一个任务,效率会非常低。因此就有了事件循环的概念。

事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。(来自MDN)

那事件循环的逻辑到底是怎样的呢?

要理解事件循环,首先要清楚JS代码在执行时包含的几个概念:栈、堆和队列。

运行时的概念

  函数的调用形成了一个若干帧组成的栈。

  函数被调用时,会形成一个包含参数和局部变量的帧压入栈中,生成第一帧;如果函数内部调用了其它函数,也是同样的逻辑压入栈中,生成第二帧。因此栈是LIFO(First In Last Out)结构,因此后进入的帧先执行并弹出,直至栈为空。

  块状存储空间,对象类型的变量被存储在堆中。

  • 队列

  存放JS运行时待处理的消息,每一个消息关联着一个处理它的回调函数。

  队列是一个FIFO(First In First Out)结构,因此运行时会按照消息进入顺序取出消息,作为参数去调用与之关联的函数,并生成一个新的帧,放入执行栈。直到执行栈为空,再进行下一轮消息处理的循环。

同步任务和异步任务

我们还知道,JS的代码要执行的任务可以分为同步任务和异步任务。

简单地说,同步任务就是普通的JS代码,而异步任务就是ajax请求、setTimeout、setInterval这些需要等待才能得到结果的任务。

执行逻辑

同步任务和异步任务的处理方式有所不同:

  1. 代码进入执行栈。
  2. 判断拿到的代码是同步任务还是异步任务,如果是同步任务则进入步骤 3 ;如果是异步任务,则进入步骤 4。
  3. 同步任务直接进入主线程执行。
  4. 异步任务进入 Event Table 进行注册,当指定事件执行完成,Event Table会将回调函数移入 Event Queue中。
  5. JS引擎判断主线程任务是否执行完毕?若是,则进入步骤 6;否,则继续步骤 3。
  6. 查看 Event Queue 是否为空,如果不为空则读取队列中的函数,进入主线程执行。

上述流程不断重复,就形成了JS的事件循环。

来看一段简单的代码示例:

setTimeout(() => {
    console.log('我是异步任务');
},0)
(function (){
    console.log('我是同步任务');
})()

// 我是同步任务
// 我是异步任务

上述代码的执行流程如下:

  1. setTimeout 是异步任务,进入 Event Table,并注册回调函数,也就是代码里的箭头函数。
  2. 立即执行函数为同步任务,进入主线程直接执行,输出“我是同步任务”。
  3. setTimeout 前置条件执行完成,回调函数进入 Event Queue。
  4. JS引擎检测到主线程任务已经执行完毕,因此从 Event Queue 读取 setTimeout 的回调函数执行,输出“我是异步任务”。

从上面的代码也可以看出,即使 setTimeout 的延迟事件为 0 ,也是在同步任务之后执行的。

同步任务比较简单,容易理解,就不再赘述了。下面重点说一下异步任务的种类,同时又会引出 宏任务(macro-task)和 微任务(micro-task)的概念。

常见的异步任务主要包括 setTimeout、setInterval、Promise。

setTimeout(callback, time)

  我们都只setTimeout是延迟time时间后执行callback回调函数。但是,真的是在延迟time时长后准时执行吗?我们来做个试验:

console.log(new Date())
setTimeout(() => {
    console.log(new Date())
    console.log('3s 后执行')
}, 3000)
for(let i=0; i < 40000; i++){
    console.log(i-i)
}

// Sun Jun 14 2020 13:51:50 GMT+0800 (中国标准时间)
// 40000个0
// Sun Jun 14 2020 13:51:58 GMT+0800 (中国标准时间)
// 3s 后执行

  上面的例子输出结果仅供参考,间隔时间在不同的电脑上可能不尽相同。但是可以看到,在setTimeout的回调执行时,已经不止过了3s了,for循环执行的次数越多,效果会越明显。

  到底为什么会这样呢?前面我们说过,异步任务首先会进入 Event Table,当前置条件完成,也就是3s后,setTimeout的回调函数会进入 Event Queue。但是,重点来了,这个时候我的for循环还没有执行完啊,主线程不为空,所以Event Queue中的回调函数还没有办法进入到主线程执行。

  所以,setTimeout的延迟时间并不准确,它的回调具体什么时候执行,还要取决于主线程任务什么时候执行完,然后去Event Queue读取任务,轮到它,它才能进入主线程执行。

setInterval(callback, time)

  和setTimeout类似,也是在延迟指定时间之后执行回调函数,不同的是,setInterval是循环执行,也就是每过指定时间将回调函数放入 Event Queue 一次,等待进入主线程执行。

Promise

  ES6提供的一种异步编程解决方案,具体使用方法可以查看阮一峰老师的《ECMAScript 6 入门》 或者 MDN文档

  promise接受的参数函数是作为同步代码执行的,而在promise被 resolve() 或者 reject() 之后的回调函数是异步任务。

前面说了,JS会先执行同步任务,然后执行异步任务。同步任务当然是按照代码顺序执行的,那异步任务也是吗?我们做个试验:

setTimeout(() => {
    console.log('我是setTimeout')
}, 0);
new Promise(function(resolve, reject){
    console.log('promise 前置条件');
    resolve('promise 成功');
}).then(res => {
    console.log(res)
})

// promise 前置条件
// promise 成功
// 我是setTimeout

从上面的例子很明显可以看出,异步任务并不是按照代码顺序执行的。此处就不得不提宏任务和微任务的概念,在JS中,一个任务除了被分为同步和异步任务,还会被细分为宏任务和微任务。也因为宏任务和微任务的执行逻辑不一样,因此回调函数进入的任务队列也不一样,所以就有了 宏任务的Event Queue和微任务的Event Queue两种任务队列。

宏任务和微任务

宏任务

宏任务主要包括:setTimeout、setInterval、普通JS代码、I/O操作;

微任务

微任务主要包括:promise、process.nextTick()

但是宏任务中的普通JS代码是同步代码,已经明确是直接进入主线程执行了,所以在讨论异步任务的执行顺序时,我们忽略它,只关心setTImeout和setInterval,这两个也是平时最常见到的。

执行逻辑

在有宏任务和微任务的概念加入后,JS代码的整体执行逻辑变为:

  1. 得到的JS代码进入主线程
  2. 判断遇到的代码:如果是同步代码则进入步骤 3;如果是异步代码则进入步骤 4。
  3. 同步代码直接进入主线程执行。
  4. 对异步代码进行分类:如果是promise,则new promise以及接受的函数参数立即执行,then和catch的回调函数进入 微任务的Event Queue;如果是setTimeout或者setInterval(需要达到指定时间),则将回调函数注册到 宏任务的Event Queue。
  5. JS引擎判断主线程是否为空,如果为空,则读取 微任务Event Queue 中所有的消息,并依次执行。执行完毕后进入步骤 6。
  6. 主线程和微任务 Event Queue 都为空后,读取 宏任务Event Queue 中的第一个消息进入主线程执行。执行完毕后进入步骤 5。

上面的过程反复执行,直到代码执行完,事件循环也就完成了。

可以将执行顺序简单地总结为: (掘金的md编辑器竟然不支持流程图)

全文核心

同步代码-->全部微任务-->单个宏任务-->全部微任务-->单个宏任务.....(循环往复)

(本文最核心的一行)

一个比较复杂的例子

我们看一个例子:

setTimeout(function () {
  console.log(" set1");
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2 ");
  });
});

new Promise(function (resolve) {
  console.log("pr1");
  resolve();
}).then(function () {
  console.log("then1");
});

setTimeout(function () {
  console.log("set2");
});

console.log(2);

new Promise(function (resolve) {
  resolve();
}).then(function () {
  console.log("then3");
});

按照上面的执行流程进行分析:

  1. 首先遇到的setTimeout,为了方便区分,我们后面使用setTimeout_1代替。setTimeout_1没有延迟时间,因此将回调函数 setTimeout_1_function 放入宏任务Event Queue。
任务分类 消息
宏任务Event Queue setTimeout_1_function
微任务Event Queue
  1. 遇到new Promise(),参数函数立即执行,输出“pr1”,resolve()被调用,promise变为fulfilled状态,then回调函数promise_1_then进入微任务。
任务分类 消息
宏任务Event Queue setTimeout_1_function
微任务Event Queue promise_1_then
输出: pr1
  1. 后面又是setTimeout,使用setTimeout_2代替,它的回调函数setTimeout_2_function进入宏任务队列。
任务分类 消息
宏任务Event Queue setTimeout_1_function、setTimeout_2_function
微任务Event Queue promise_1_then
输出: pr1
  1. 遇到console.log(2);同步代码,立即执行,输出2。
任务分类 消息
宏任务Event Queue setTimeout_1_function、setTimeout_2_function
微任务Event Queue promise_1_then
输出: pr1、2
  1. 最后遇到promise,参数函数立即执行,函数内只有resolve(),then回调函数promise_2_then进入微任务队列。
任务分类 消息
宏任务Event Queue setTimeout_1_function、setTimeout_2_function
微任务Event Queue promise_1_then、promise_2_then
输出: pr1、2
  1. 到此第一轮事件循环结束,开始第二轮循环。执行全部的微任务,先取出promise_1_then回调函数,输出“then1”。

  为了方便,再贴一次回调函数的代码

function () {
  console.log("then1");
}
任务分类 消息
宏任务Event Queue setTimeout_1_function、setTimeout_2_function
微任务Event Queue promise_2_then
输出: pr1、2、then1
  1. 微任务队列还有promise_2_then,取出执行,输出“then3”。
function () {
  console.log("then3");
}
任务分类 消息
宏任务Event Queue setTimeout_1_function、setTimeout_2_function
微任务Event Queue
输出: pr1、2、then1、then3
  1. 此时微任务队列已经为空,执行单个宏任务。取出setTImeout_1_function执行。

  console.log("set1")为同步代码,直接执行,输出“set1”。

  遇到new Promise(),resolve()立即执行,then回调函数promise_3_then放入微任务队列。

  单个宏任务执行完成

function () {
  console.log(" set1");
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    new Promise(function (resolve) {
      resolve();
    }).then(function () {
      console.log("then4");
    });
    console.log("then2 ");
  });
}
任务分类 消息
宏任务Event Queue setTimeout_2_function
微任务Event Queue promise_3_then
输出: pr1、2、then1、then3、set1
  1. 执行全部微任务,取出 promise_3_then 执行。

  遇到new Promise(),resolve()立即执行,then回调函数promise_4_then进入微任务队列。

  遇到console.log("then2"),立即执行,直接输出“then2”

function () {
  new Promise(function (resolve) {
    resolve();
  }).then(function () {
    console.log("then4");
  });
  console.log("then2 ");
}
任务分类 消息
宏任务Event Queue setTimeout_2_function
微任务Event Queue promise_4_then
输出: pr1、2、then1、then3、set1、then2
  1. 经过上一步的执行,微任务队列仍然不为空,因此取出promise_4_then执行。

  直接输出“then4”。

function () {
  console.log("then4");
}
任务分类 消息
宏任务Event Queue setTimeout_2_function
微任务Event Queue
输出: pr1、2、then1、then3、set1、then2、then4
  1. 此时微任务队列为空,执行单个宏任务。取出setTimeout_2_function执行。

  直接输出“set2”

function () {
  console.log("set2");
}
任务分类 消息
宏任务Event Queue
微任务Event Queue
输出: pr1、2、then1、then3、set1、then2、then4、set2
  1. 经过上一步执行,此时微任务队列和宏任务队列均已为空,事件循环执行完毕。所以最终的输出结果如下:
输出: pr1、2、then1、then3、set1、then2、then4、set2

至此,关于事件循环基本就说完了。其实我们只需要分清楚哪些属于宏任务,哪些属于微任务,然后再记住宏任务和微任务的循环逻辑,这种输出顺序的题目基本就可以搞定了。

这篇文章我也是一边理解JS事件循环的机制一边以我觉得比较容易理解的方式写的,可能存在我理解不到位甚至是错误的地方,欢迎大家发现问题并指正,我们一起进步~

在此感谢大佬 @ssssyoki 的文章《这一次,彻底弄懂 JavaScript 执行机制》 的帮助,写的真的很详细,小白神器!!!

主要参考文章: