事件循环可不仅仅是事件循环

271 阅读11分钟

前言

这个标题可能会让人有点迷惑,会什么事件循环不仅仅是事件循环,这不是语病吗?

哎,各位读者老爷,我的本意是这样的,学习事件循环可不仅仅的学习事件循环。

在学习这玩意的路上,我们得知道这些东西

  • 线程与进程
  • 同步任务与异步任务
  • 宏任务与微任务

然后才能基本的了解事件循环大概是个什么东西。现在读者老爷们,你还觉得我是一个标题党吗? 😭

进程与线程

我们可以在电脑的任务管理器中查看到正在运行的进程,可以认为一个进程就是在运行一个程序,比如用浏览器打开一个网页,这就是开启了一个进程。但是比如打开3个网页,那么就开启了3个进程,我们这里只研究打开一个网页即一个进程。

一个进程的运行,当然需要很多个线程互相配合,比如打开QQ的这个进程,可能同时有接收消息线程、传输文件线程、检测安全线程......

当然这里我只是举了一个很肤浅的例子来解释进程与线程的关系,但是实际上这两者的关系比这复杂的多。文末会给大家推荐一些文章帮助大家学习进程与线程的概念,这里为了减轻大家的学习成本就不展开了。

同步任务与异步任务

同步任务

"同步任务"就是后一个任务等待前一个任务结束,然后再执行,程序的执行顺序与任务的排列顺序是一致的、同步的。

异步任务

"异步任务"则完全不同,每一个任务有一个或多个回调函数(callback),前一个任务结束后,不是执行后一个任务,而是执行回调函数,后一个任务则是不等前一个任务结束就执行,所以程序的执行顺序与任务的排列顺序是不一致的、异步的。

常见的异步任务

  • 定时器
  • 发请求,类似ajax,axios等
  • 触发各种DOM事件(AddEventListener)

直男连问

在了解进程与线程的关系以及知道了同步任务与异步任务后,在这里问大家几个问题?

第一个问题:JavaScript是什么线程的语言?

我相信这个问题大家肯定嗤之以鼻的回答:单线程。

第二个问题:单线程的语言一次可以执行几个任务?

当然是一个啦。

第三个问题: 为什么要这样设计,一次一多个不爽?

这个问题可能就会有些同学不知道啦,仔细看看下面的答案。

这与浏览器的用途有关,JS的主要用途是与用户互动与操作DOM,设想一段JS代码,分别在两个并行但不相关的线程中运行,一个在DOM上添加内容,另一个线程在删除DOM,那么会发生什么?以哪个为准?所以为了避免复杂性,JS一开始就是单线程的,以后也不会改变。

第四个问题:既然JS是单线程的,单线程只能处理同步任务,那么setTimeOut,onClick回调,ajax这些异步任务该如何处理呢?

好了,既然你已经思考到这里并且遇到瓶颈了,说明你有必要好好了解一下事件循环是什么。let's go。

既然JS是单线程的,单线程只能处理同步任务,那么setTimeOut,onClick回调,ajax这些异步任务该如何处理呢?

答案是:

浏览器是多线程的,即浏览器搞了几个其他辅助线程去辅助JS线程运行。

那么其他辅助线程指的是那些辅助线程呢?它们又是如何帮助JS线程的呢?

继续看

  • 为了帮助JS异步处理定时器的回调函数,浏览器让定时器触发线程去辅助JS线程。

  • 为了帮助JS异步处理发送接收请求,浏览器让http异步线程去辅助JS线程。

  • 为了帮助JS异步处理操作dom触发的回调函数,浏览器让浏览器事件线程去辅助JS线程。

JS线程真有牌面。

这里解决了回答了辅助线程有哪些,那么它们辅助的方式是怎样的呢?

别急啊,继续看好吧,老爷们。

讲了这么多文字,有些老爷可能看累了。

小二,上代码。

1 var a = 2;
2 setTimeout(fun A)
3 ajax(fun B)
4 console.log(a)
5 dom.onclick(func C)

分析执行过程

主线程执行这段代码时,执行到2 setTimeout(fun A)时,将这段代码交给定时器触发线程去处理。

碰到3 ajax(fun B)时,将其交给http异步线程去处理。

遇到4 直接执行

碰到5 dom.onclick(func C)时,将其交给浏览器事件线程去处理。

注意:这几个异步代码的回调函数funA,funB,funC,各自的线程都会保存着,因为需要在未来的某个时刻将回调函数讲给主线程去执行。

所以,这几个线程主要做这两件事:

  1. 执行主线程扔过来的异步代码,并执行代码
  2. 保存回调函数,在未来的某个时刻,通知EventLoop轮询处理线程过来处理相应的回调函数然后执行

那么那边JS线程正在处理同步代码,这边异步任务已经执行完了,回调函数放哪里啊。答案是消息队列

消息队列

消息队列也叫任务队列。可以理解为一个静态的队列存储结构,用来存储异步成功后的回调函数字符串,先异步成功执行的回调放在消息队列的前面,后异步成功执行的回调放在消息队列的后面。

注意:是异步成功后,才将回调放入消息队列中,而不是一开始就将所有的异步函数全部放入消息队列。

广义上的事件循环

至此,事件循环三剑客:JS主线程,异步线程(包括定时器触发线程等),消息队列正式出道。

当然啦,三剑客作为大侠怎么可能事事亲为,所以他们还有个小跟班,EventLoop轮询处理线程。这玩意就是负责在三剑客之间进行沟通。具体来说就是

在JS主线程遇到异步代码时通知异步线程来执行,在异步线程处理异步任务结束后,将回调函数交给消息队列。

来张图片加深印象

文字描述广义上的事件循环

  1. 因为JS是单线程的语言。在代码运行时,通过将不同的函数的上下文压入栈中来保证代码的有序运行。
  2. 在执行同步代码时,如果遇到异步代码时,JS引擎不会一直等待其返回结果,而是会将这个事件交给异步线程去处理,继续执行执行栈中的其他任务
  3. 异步事件执行完之后,会将对应的回调加入与当前执行栈不同的另一队列消息队列中等待执行。
  4. 当JS主线程中的执行栈空了之后,会去消息队列中有没有要执行的代码,如果有会取出消息队列中第一个函数放到JS主线程中执行
  • 上面的2,3,4步不断循环,就是广义上的事件循环啦。

代码分析广义上的事件循环

var a = 111;


setTimeout(function() {
    console.log(222)
}, 2000)

ajax.get(url)  // 假设该http请求花了3秒钟
.then(function() {
    console.log(333)
})

dom.onclick = function() {  // 假设用户在4秒钟时点击了dom
    console.log(444)
}
console.log(555)
// 结果
555
222
333
444

步骤1

步骤2

步骤3

步骤4

注意: 图里的队列里都只有一个回调函数,实际上有很多个回调函数,如果主线程里执行的代码复杂需要很长时间,这时队列里的函数们就排着,等着主线程啥时执行完,再来队列里取。

宏任务与微任务

如上就是广义的事件循环机制,接下来我们再深入细化一下。

上面的消息队列中的任务,可以再细分为两种

宏任务

#浏览器Node
主代码块
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame

微任务

#浏览器Node
process.nextTick
MutationObserver
Promise.then catch finally

注意:new Promise执行本身时是属于同步代码,只有.then才是微任务

微任务与宏任务的由来

我们为什么要将消息队列中的任务再做区分呢?

这里我本人觉得有一篇文章讲的很好,如果让我来讲的话,肯定没有它说的更清楚,因此也希望小伙帮们去看下这篇博客在回来继续看下面的内容。

微任务、宏任务与Event-Loop

完整的事件循环

文字描述完整的事件循环

总而言之,有了宏任务与微任务的区别之后。

事件循环的过程变成了下面这样

1.一开始整个脚本作为一个宏任务执行(栈中没有就从事件队列中获取)

2.执行过程中同步代码直接执行,宏任务进入宏任务队列,微任务进入微任务队列

3.当前宏任务执行完出队,检查微任务列表,有则依次执行,直到全部执行完

4.当前宏任务执行完毕,执行浏览器UI线程的渲染工作

5.检查是否有Web Worker任务,有则执行

6.执行完本轮的宏任务,调到下一个宏任务,回到2,依此循环,直到宏任务和微任务队列都为空

宏任务与微任务

蓝色块表示宏任务队列,橙色表示微任务队列,执行顺序是先执行当前所在横行的同步任务,再执行所在横行的微任务,再执行下一横行的宏任务

代码分析完整的事件循环

console.log('start')
  setTimeout(() => {
    console.log('timer1')
    Promise.resolve().then(() => {
      console.log('promise1')
    })
  }, 0)
  setTimeout(() => {
    console.log('timer2')
    Promise.resolve().then(() => {
      console.log('promise2')
    })
  }, 0)
  setTimeout(() => {
    console.log('timer3')
    Promise.resolve().then(() => {
      console.log('promise3')
    })
  }, 0)
  new Promise(function(resolve) {
    console.log('promise4');
    resolve();
  }).then(function() {
    console.log('promise5')
  })
  console.log('end')

代码分析

  1. 开始进行主代码块,这里我们称为宏任务1,简称宏1,向下制作,第一行,同步代码,打印start
  2. 遇到setTimeout,宏任务2,放到下一次执行。
  3. 又遇到setTimeout,宏任务3,下下一次执行。
  4. 又遇到setTimeout,宏任务4下下下一次执行。
  5. 遇到new Promise 立刻执行,打印promise4。同时resolve,记录当前Promise状态变化为fulfiled。
  6. 继续向下发现then语句,微任务1,在当前宏任务执行之后立刻执行。
  7. 最后一行,同步代码,立刻打印end
  8. 宏1同步代码执行完毕看看当前宏任务后有没有微任务,发现微1,立刻执行,打印promise5
  9. 开始执行宏任务2也就是第一个定时器,打印timer1
  10. 发现微任务2,宏任务2执行完毕,执行微任务2,打印promise1
  11. 执行宏任务3,也就是第二个定时器,打印timer2
  12. 发现微任务3,宏任务3执行完毕,执行微任务3,打印promise2
  13. 执行宏任务4,也就是第三个定时器,打印timer3
  14. 发现微任务4,宏任务4执行完毕,执行微任务4,打印promise3

所以最后的结果是

start
promise4
end
promise5
timer1
promise1
timer2
promise2
timer3
promise3

再留一道题

console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

有兴趣的同学可以做一下,nextTick没遇到过没关系,把他当作一个微任务就行。

其实还想讲一下async和await的,但是有感觉自己也没有研究透彻,所以这里就不献丑了。

如果有想要了解的同学,我会在文末给出几篇写的不错的文章供大家参考。

结语

感谢老爷看到这里。

参考文章

小白入门 微任务、宏任务与Event-Loop

刷题巩固 【建议星星】要就来45道Promise面试题一次爽到底(1.1w字用心整理)