彻底了解js事件循环机制

123 阅读9分钟

背景

最近在网上看博文的时候经常看到Js事件循环相关的内容,但是我对这块儿也只是知道部分原理和流程,整体过程所涉及的细节及技术问题了解的并不深入,而且时间长了也就忘记了,于是想着干脆来一次说干就干的深入总结,接下来将用简单易懂的语言及绘图来描述整个过程。(没学过js的也能看懂)

举个栗子🌰

举个大家应该经历过的例子开启我们的知识点旅程吧! 如下是打工人的日常工作流:

image.png 下面结合js事件循环来解释一下这张流程图吧!(稍微有点儿长,仔细阅读下吧,也挺有意思的)

图上主流程----------->执行栈

有先后顺序的支流程----------->幕后线程

新的一天开始了,你来到公司,工作就是你一整天的主流程,也就是主线程,你就是主线程中的执行者,也就是执行栈,你自己的事情就得自己来干,也就是所有的js代码都会在执行栈里运行。你自己的工作干的正好的时候,发现需要找rd给一份接口文档,这时你得开启了一个幕后线程(随便起名的)去记录找rd要接口文档这件事情,但是也不能一直等吧,自己的其他工作也得干呀,正如幕后线程不会去阻塞主线程的执行。之后你打算写个技术文档,沉淀下刚学的知识。半个小时后,rd接口文档返回给你了(即任务队列里有了新的任务,等待执行),此处你(执行栈)发现有待办任务需要完成,则就执行该任务队列的任务,此时的你拿到接口文档后继续工作。突然之间通知说半小时后需要参加个会议,啊,咋这么多事儿呀。此时,你把半小时后开会这件事情加入了幕后线程,等待半小时后加入任务队列,你(执行栈)就会去执行它。但是离开会还有半个小时,你工作也干了很久了,太累,必须要休息会儿,于是你先打开公司内网看下相亲论坛,之后再打开手机刷了会儿微博,最后又打开微信和朋友聊了会儿天儿(以上三种任务可以理解为另一种任务,也是需要执行栈按顺序执行的)。玩耍的时间总是过得很快,开会时间也到了(幕后线程中的半小时开会这件事儿有结果了,加入任务队列等待执行栈去执行),然后你就在无聊的会议中度日如年(会议中加入了幕后线程),那翻看下邮箱吧(不阻塞主线程执行),过了很久后,会议结束了(加入任务队列,等待主线程执行),然后你就迫不及待的去吃饭了(主线程收到会议结束的消息后就执行了),接着你又干了很多事儿后,终于下班了。。。明天又是循环的一天。 以上分析中,一天内可以有很多循环,这个循环的定义就是主线程会不断的去任务队列中执行各种任务,一次循环就是从主线程执行到任务队列再回到主线程执行

接下来直接进入正题吧!

原理剖析

事件循环原理

浏览器的js是单线程的,在同一时刻,只能有一个代码段执行,但是它是怎么做到异步请求等其他的操作呢?

这正是js的事件循环起的作用。

js的主线程是有一个执行栈的,所有的js代码都会在执行栈中去运行。当主流程执行过程中,如果遇到一些异步代码,例如用户的点击操作、接口请求、定时任务等操作时,浏览器会将这些事件放到一个幕后线程中去等待执行,并不会阻塞主线程的执行,主线程会继续去运行执行栈中的代码,等到幕后线程有消息返回时(即回调函数),会把改事件的回调函数放到任务队列中等待主线程去执行。而后当主线程运行完执行栈中的所有代码后,将会去检查任务队列是否需要有任务需要执行,如果有的话,就将此任务放到执行栈中去执行。如果任务队列为空,那么主线程将会一直循环检查任务队列是否有需要处理的任务。

插播一条消息:任务队列都是先进先出的 那么问题又来了,如果有多个任务队列,该先执行哪一个任务队列里的任务呢?

这就涉及到了js的任务队列知识。

js是有两个任务队列的,一个是宏任务队列Macrotask Queue,一个是微任务队列Microtask Queue。

宏任务包括:setTimeout、setInterval、用户交互操作、script标签中的同步代码等。

微任务包括:promise.then、Object.observe、MutationObserver等。

那么执行栈应该先去执行哪个任务呢?

image.png 如上图,谨记:js一次事件循环只执行一个宏任务

  1. 事件循环机制首先检查宏任务队列是否有任务,若有,则会将宏任务队列队首的任务(只有一个)放入执行栈中区执行
  2. 检查微任务队列是否有任务,若有,则将这一循环内的所有微任务队列的任务从队首依次执行完,直到该队列为空
  3. 如果上述微任务队列为空,则会进入新的一次事件循环,继续执行第一步
  4. 如此,循环往复

在如上的执行过程中,新增的微任务会在当前的执行周期内完成,而新增的宏任务只能等到下一次事件循环才能执行(一次事件循环只执行一个宏任务)。

总结:执行栈总是会先执行宏任务,再执行微任务,只有当微任务队列为空时,才会开启下一次的事件循环

setTimeout定时问题

先抛结论:setTimeout定时不一定是准确的。

setTimeout接收两个参数,待加入队列的任务以及定时时间t(可选)。

这个定时时间并不是t时间后就一定会运行任务,而是代表了该任务实际被加入到宏任务队列的最小定时时间。

如果当前执行栈为空并且宏任务微任务队列都为空的话,则在t时间之后,该任务就会立马被执行。

但是如果队列中有其他的任务,则setTimeout定时的任务必须等待其他的任务处理完后,才会进行处理。 因此,setTimeout的配置参数时间值仅仅表示加入队列的最小时间,而非确切的任务执行等待时间。

趁热打铁

根据以上结论,来做个小题结束今天的知识旅程吧!

console.log(1) // 第一段代码

setTimeout(function() {
  console.log(2)
}, 0); // 第二段代码

setTimeout(function() {
  console.log(10)
  new Promise(function(resolve) {
    console.log(11)
    resolve()
  })
  .then(function() {
    console.log(12)
  })
  .then(function() {
    console.log(13)
  })
}, 0); // 第三段代码

Promise.resolve()
  .then(function() {
    console.log(7)
  })
  .then(function() {
    console.log(8)
  }) // 第四段代码
  
console.log(9) // 第五段代码

先在纸上写一下自己心中的打印结果吧!

正确答案

1
9
7
8
2
10
11
12
13

答案详解

首先代码是按照顺序执行的

第一次事件循环(从上往下顺序执行):

  1. 第一段代码被执行,输出1
  2. 顺序执行第二段代码,发现是setTimeout,加入宏任务队列,记为A
  3. 接着往下执行第三段代码,发现还是是setTimeout,加入宏任务队列(不断改代码段内部是同步还是异步,先不执行),记为B
  4. 继续往下执行第四段代码,发现是promise.then,将它两个then回调加入微任务队列
  5. 继续执行最后一段代码,输出9
  6. 根据事件循环定义,此时微任务队列不为空,将会执行微任务队列中的任务(直到微任务队列为空),输出7,8

第二次事件循环:

  1. 第一次事件循环结束,执行栈为空,此时从宏任务队首取一个任务开始执行,找到了A任务,并执行,输出2
  2. 此时微任务队列为空,将开启第三次事件循环,此时宏任务队列中仅剩B任务

第三次事件循环:

  1. 第二次事件循环结束,执行栈为空,宏任务队列不为空
  2. 执行宏任务队列中的B任务,先输出10,接着输出11(new promise为同步任务,其内部函数为同步操作),仅接着遇到promise.then,将其加入微任务队列,等待执行
  3. 执行栈到微任务队列执行,顺序输出12,13

可能有人会问了,第一次事件循环不是先执行宏任务在执行微任务吗?应该先输出1,9 然后是2呀,为啥是 7,8呢?

这是因为程序一开始就是跑的宏任务,也即可以理解为主线程中跑的就是一种宏任务,而根据原理可知,一次事件循环中只能执行一个宏任务,因为上述代码执行完主线程代码(即输出1,9后),会去检查微任务队列是否为空,此时微任务队列不为空,则输出 7, 8

这也就说明如果我们写的代码会不断的产生微任务时,主线程会一直处理微任务,而宏任务迟迟得不到响应,Web 应用程序就无法正常处理与用户的交互,例如点击或滚动(宏任务),这就阻塞了主线程的执行。

以上就是我个人对于js事件循环的理解,如果有问题,欢迎大家批评指正。