JavaScript事件循环探索

一直对js的事件循环不是很清晰,最近看了JavaScript忍者秘籍的第13章后,有了一些感悟,特此总结一下,分享给大家。

单线程

众所周知,JavaScript是单线程执行模型,同一时刻只能执行一个代码片段,一个任务开始后知道运行完成,不会被其他任务中断。当一个任务花费的时间很长的话,用户就会明显的感觉到卡顿。浏览器为了解决这个问题引入了事件循环的概念(Event Loop)。

事件循环

事件循环具有至少两个队列处理任务。任务分为两类,宏任务(macro-task)和微任务(micro-task)。

  1. 宏任务代表一个个离散、独立的工作单元,运行完之后,浏览器可以继续其他的调度。包括:创建文档对象,解析HTML,执行JavaScript,以及各种事件……
  2. 微任务是更小的任务,主要用户更新应用程序的状态,必须在浏览器任务继续执行其他任务之前执行。微任务需要尽可能快地通过异步方式执行,同时不能产生全新的微任务。包括promise、回调函数、DOM发生变化……

仅包含宏任务

1
2
3
// 主线程JavaScript运行15ms
btn1.addEventListener('click', function() {运行 8ms}, false);
btn2.addEventListener('click', function() {运行 5ms}, false);

​ 现在假设主线程运行15ms, 在第5ms单击btn1,在第12ms的时候单击btn2。基于单线程执行模型,单击按钮之后不会立即执行对应的处理函数,因为一个任务一旦开始就不会被另一个任务中断。因此,在主线程执行的15ms期间,按钮的单击处理函数放入队列。当主线程执行完成也就是15ms之后,程序开始处理微任务,因为当前不存在微任务,跳过此步骤,开始执行更新UI。

​ 之后进入第二次循环,也就是开始执行btn1的处理函数,需要运行8ms,btn2处理函数在队列中等待。当btn1处理函数执行完之后,浏览器检查微任务是否存在和是否更新UI,删除任务队列里的btn1的处理函数。

​ 最后进入第三次循环,开始执行btn2的处理函数,需要运行5ms,处理函数执行完之后,检查微任务和是否需要更新UI,删除任务队列里的btn2的处理函数,最终任务队列为空,循环结束。

同时含有宏任务和微任务

1
2
3
4
5
6
7
8
// 主线程JavaScript运行15ms
btn1.addEventListener('click', function() {
Promise.resolve().then(() => {
运行 4ms
});
运行 8ms
}, false);
btn2.addEventListener('click', function() {运行 5ms}, false);

本例中在btn1的事件处理函数里增加了一个立即兑现的Promise,需要运行4ms。

现在代码的执行顺序为:

1. 主线程执行15ms,在5ms和12ms的时候分别将处理函数放入任务队列,更新UI。
2. 15m后处理btn1事件处理函数,发现Promise,放入微任务队列,btn1事件处理函数继续执行8ms,检查微任务队列发现有Promise回调函数,然后开始执行Promise回调函数,运行4ms,继续检查微任务队列,如果为空,检查是否需要更新UI,进入下一轮循环。
3. 处理btn2的事件处理函数……

计时器

基于上面的事件循环机制,现在我们来看一种特殊类型的事件:计时器。浏览器提供了两种创建计时器的方法 setTimeoutsetInterval。 他们都挂载在 window 对象上。

setTimeout:在指定的延迟时间结束时执行一次回调函数,返回标识计时器的唯一值,可用于取消定时器(clearTimeout(id))。

setInterval:按照指定的延迟间隔不断的执行回调函数,返回标识计时器的唯一值,可用于取消定时器(clearInterval(id))。

事件循环中的定时器

1
2
3
4
5
6
7
8
// 主线程JavaScript运行18ms
setTimeout(function() {
运行6ms;
}, 10);
setInterval(function() {
运行8ms;
}, 10);
btn1.addEventListener('click', function() {运行 10ms}, false);

👆代码的执行过程是什么呢?

现在我们想象一下主线程代码需要运行18ms,在第6ms的时候用户点击了按钮,在第10ms延迟计时器到期,间隔计时器第一次触发。

我们知道一个任务一旦开始执行,就无法被其他任务中断。所以,6ms将事件处理函数加入队列,10ms分别将延迟计时器和间隔计时器回调放入队列。运行到18m主线程执行完毕,检查微任务队列和更新UI,进入下一个时间循环。开始执行btn1事件回调,运行10ms,这时候在btn1事件回调运行的过程中,间隔计时器第二次到期,但是任务队列里面已经有一个间隔计时器处理函数,所以忽略这个处理函数。btn1事件回调运行结束,检查微任务队列和更新UI,进入下一个事件循环。开始执行延迟计时器处理函数,运行6ms,在这个过程中间隔计时器第三次到期,但是由于任务队列已经有了处理函数,继续忽略。延迟计时器处理函数运行完毕,检查微任务队列和更新UI,进入下一个事件循环。现在开始执行间隔计时器处理函数,运行8ms,在这期间间隔计时器第四次到期,这时候任务队列里没有处理函数,所以将这次的处理函数放入任务队列,间隔定时器处理函数运行完成,检查微任务队列和更新UI,进入下一个事件循环,然后重复运行间隔定时器……

通过以上的执行过程我们发现,我们只能控制计时器何时被加入队列,而无法控制何时执行。

最后,JavaScript的事件循环是这门语言非常重要的基础,由于我水平有限以上只是简单总结了一下它的执行过程。大家可以深入研究一下Nodejs的事件循环