浏览器中的事件循环机制【看完就懂】

什么是事件循环机制

相信大家看过很多类似下面这样的代码:

function printNumber(){
    console.log('printNumber');                                                
}
setTimeout(function(){
    console.log('setTimeout 1000')
}, 1000);

setTimeout(function(){
    console.log('setTimeout 0')
});

printNumber();

new Promise((resolve, reject) => {
    console.log('new promise');
}).then(function(){
    console.log('promise resolve');
})

然后让我们说出这段代码的输出结果,那这段代码的输出结果其实就是由事件循环机制决定的。

我们都知道JS引擎线程是专门用来解析JavaScript脚本的,所有的JavaScript代码都由这一个线程来解析。然而这个JS引擎是单线程的,也就意味着JavaScript程序在执行时,前面的必须处理好,后面的才会执行。

但是JavaScript中除了一些顺序执行的逻辑代码,还有很多异步任务,比如Ajax请求定时器等。如果JS引擎在单线程解析JavaScript时遇到了一个Ajax请求,那就必须等Ajax请求返回结果才能继续执行后续的代码,很显然这样的行为是非常低效的。

那为了解决这样的问题,事件循环机制这样的技术就显得尤为重要:

"JS引擎"在顺序执行"JavaScript"代码时,如果遇到"同步代码"立即执行;

如果遇到一些"异步任务"就会将这个"异步任务"交给对应的模块处理,然后继续执行后续代码;

当一个"异步任务"到达触发条件时就将该"异步任务"的回调放入"任务队列"中;

当"JS引擎"空闲以后,就会从"任务队列"读取和执行异步任务;

补充内容:

1.JS引擎线程也被称为执行JS代码的主线程,后续如果出现主线程这样的描述,指的就是JS引擎线程
2. 任务队列属于数据结构中的队列,特性是先进先出
3. 有关JavaScript同步任务异步任务的分类下面一节会介绍。
4. 本文只讨论浏览器环境下的事件循环机制,后续的描述和代码演示均基于浏览器环境(Node中的事件循环机制不做分析)。

JavaScript任务的分类

前面我们简单介绍过事件循环机制执行JS代码的顺序,那首先我们需要知道在JavaScript那些代码是同步任务,那些是异步任务

接下来我们对JavaScript中的任务做一个分类:

这个分类很重要哦 不同的类型的任务执行顺序不同~

任务的执行顺序

接着事件循环机制中JS引擎对这些任务的执行顺序描述如下:

  • 步骤一: 从<script>代码开始,到</script>代码结束,按顺序执行所有的代码。

  • 步骤二: 在步骤一顺序执行代码的过程中,如果遇到同步任务,立即执行,然后继续执行后续代码;如果遇到异步任务,将异步任务交给对应的模块处理(事件交给事件处理线程ajax交给异步HTTP请求线程),当异步任务到达触发条件以后将异步任务回调函数推入任务队列宏任务推入宏任务队列微任务推入微任务队列)。

  • 步骤三:步骤一结束后,说明同步代码执行完毕。此时读取并执行微任务队列中保存的所有的微任务

  • 步骤四: 步骤三完成后读取并执行宏任务队列中的宏任务,每执行完一个宏任务就去查看微任务队列中是否有新增的微任务,如果存在则重复步骤三;如果不存在,继续执行下一个宏任务,直到。

一定要看的补充说明 !!!

1.步骤四中描述的新增的微任务和步骤三中描述的微任务是一样的,因为异步任务只有满足条件以后才会被推入任务队列步骤三在执行时,不一定所有的微任务到达触发条件而被推入任务队列

2.所谓的到达触发条件指的是下面这几种情况:
① 定时器:定时器设置的时间到达,才会将定时器的回调函数推入任务队列中
② DOM事件:DOM绑定的事件被触发以后,才会将事件的回调函数推入任务队列中
③ 异步请求:异步请求返回结果以后,才会将异步请求的回调函数推入任务队列中
④ 异步任务之间的互相嵌套:比如宏任务A嵌套微任务X,当宏任务A对应的回调函数代码没有被执行到的时候,很显然根本不存在微任务X;只有宏任务A对应的回调函数代码被执行以后,JS引擎才会解析到微任务X,此时依然是将该微任务X交给对应的线程去处理,当微任务X满足前面描述的①、②、③的条件,才会将微任务X对应的回调推入任务队列,等待JS引擎去执行。

3.所有的异步任务都在JS引擎遇到</script>以后才会开始执行。

4.宏任务对应的英文描述为task微任务对应的英文描述为micro task宏任务队列描述为task quene微任务队列描述为micro task quene。不过很多文章也会将宏任务描述为macro task,这个没多大关系。只是有些文章会将micro task描述为微任务队列,就有些误导人了,本文为了避免描述上产生的问题,均用中文文字描述。

实践一波吧

到此事件循环机制的核心内容就讲完了,核心内容主要就两点:JavaScript任务分类任务执行顺序。只要牢牢掌握这两点,就能解决大部分问题。

那接下来我们就来实践一下。

示例一

console.log('script start');

function printNumber(){
    console.log('同步任务执行:printNumber');          
}

setTimeout(function(){
    console.log('宏任务执行:setTimeout 1000ms')
}, 1000);

printNumber();

new Promise((resolve, reject) => {
    console.log('同步任务执行:new promise');
    resolve();
}).then(function(){
    console.log('微任务执行:promise resolve');
})

console.log('script end');

这段代码是文章开头贴出来的代码,相对来说比较简单,接下来就分析一下这段代码的执行顺序以及输出结果

  • 1.首先js引擎从上到下开始执行代码

  • 2.遇到console.log直接打印script start

  • 3.遇到函数声明

  • 4.遇到宏任务setTimeout,交给定时器线程去处理(定时器线程会在1000ms后将setTimeout回调函数function(){ console.log('宏任务执行:setTimeout 1000ms') } 推入宏任务队列,等待JS引擎去执行),之后JS引擎继续执行后续代码

  • 5.遇到函数调用:printNumber立即执行并打印同步任务执行:printNumber

  • 6.遇到new Promisenew Promise构造传入的内容立即执行所以打印console.log('同步任务执行:new promise');

  • 7.遇到resolve执行promise.thenpromise.then属于微任务,因此将promise.then回调函数function(){ console.log('微任务执行:promise resolve'); }推入微任务队列

  • 8.再次遇到console.log直接打印script end

  • 9.步骤8完成,即说明同步任务执行完毕。此时就开始读取并执行微任务队列中所有的微任务。 在本例中就是执行步骤7中的promise.then即打印微任务执行:promise resolve

  • 10.本例中只有一个微任务,因此步骤9完成以后开始执行宏任务,也就是步骤4setTimeout的回调,即打印宏任务执行:setTimeout 1000ms

注意:setTimeout定时器设置的时间实际是推入任务队列的时间

经过以上的分析,得出来的打印顺序如下:

script start
同步任务执行:printNumber
同步任务执行:new promise
script end
微任务执行:promise resolve 
宏任务执行:setTimeout 1000ms

最后在浏览器中验证一下:

示例二

接下来我们来看看下面这个稍微复杂一些的案例:

console.log('script start');

setTimeout(function(){
    console.log('宏任务执行:setTimeout1 2000ms')
}, 2000);

setTimeout(function(){
    console.log('宏任务执行:setTimeout2 0ms')
}, 0);

new Promise((resolve, reject) => {
    console.log('同步代码执行: new Promise');
    setTimeout(function(){
        console.log('宏任务执行:setTimeout3 1000ms')
        resolve();
    }, 1000);
}).then(function(){
    console.log('微任务执行:promise resolve')
});

console.log('script end');

分析执行过程:

  • 1.js引擎从上到下开始执行代码

  • 2.遇到console.log直接打印script start

  • 3.遇到宏任务setTimeout,交给定时器线程处理(定时器线程会在2000ms后将setTimeout回调函数function(){ console.log('宏任务执行:setTimeout1 2000s') } 推入宏任务队列,等待JS引擎去执行),JS引擎继续执行后续代码

  • 4.再次遇到宏任务setTimeout,交给定时器线程处理(定时器线程会在0ms后将setTimeout回调函数function(){ console.log('宏任务执行:setTimeout2 0s') } 推入宏任务队列,等待JS引擎去执行),JS引擎继续执行后续代码

  • 5.遇到new Promisenew Promise构造传入的内容立即执行所以打印console.log('同步任务执行:new promise');

  • 6.接着发现new Promise的构造函数存在一个宏任务setTimeout,所以依然是交给定时器线程处理(定时器线程会在1000ms后将改setTimeout回调函数function(){ console.log('宏任务执行:setTimeout3 1000ms') } 推入宏任务队列,等待JS引擎去执行),JS引擎继续执行后续代码

  • 7.遇到console.log直接打印script end

  • 8.步骤7完成,即说明同步任务执行完毕。在这个过程中,没有产生微任务,所以微任务队列为空;同时在这个过程中产生了三个宏任务:setTimeout,按照定时器设置的时间,这三个宏任务推入宏任务队列的顺序为:setTimeout2 0mssetTimeout3 1000mssetTimeout1 2000ms,所以后续执行宏任务时先推入队列的任务先执行。(最先推入任务队列的称为队首的任务,任务执行完成后,就会从队首中移除,下一个任务就会称为队首任务)

  • 9.根据步骤8的分析,执行完同步代码以后,本应该先执行微任务队列中的所有的微任务,但是因为并没有微任务存在,所以开始执行宏任务队列中队首的任务,即setTimeout2 0ms所以会打印宏任务执行:setTimeout2 0ms

  • 10.步骤9结束以后,也就是执行完一个宏任务了;接下依然是执行微任务队列中的所有微任务,但是此时依然因为没有微任务存在,所以执行宏任务队列中的队首的那个任务,即setTimeout3 1000ms所以会打印宏任务执行:setTimeout2 0ms; 接着发现定时器setTimeout的回调函数中调用了resolve,因此产生了一个微任务:promise.then,该微任务会被推入微任务队列。

  • 11.步骤10结束以后,也是执行完一个宏任务了;接下还是执行微任务队列中的所有微任务,此时微任务队列中有一个微任务,是步骤9在执行的过程中产生的(这就是我们在前面说的任务之间的嵌套,只有外层任务的回调被执行后,内层的任务才会存在),所以执行该微任务,打印微任务执行:promise resolve

    1. 步骤10完成后,即执行完一个微任务;接着继续执行宏任务队列队首的那个任务,即打印setTimeout1 2000s
  • 13.所有的微任务宏任务执行完毕,代码结束

最终的打印顺序:

script start
同步代码执行: new Promise
script end
宏任务执行:setTimeout2 0ms
宏任务执行:setTimeout3 1000ms
微任务执行:promise resolve
宏任务执行:setTimeout1 2000ms

浏览器在验证一下:

setImmediate和setTimeout 0

关于setImmediate的作用 MDN Web Docs 是这样介绍的:

从上面的描述我们可以获取到两个有用信息:

  • 1.该方法是非标准的,目前只有最新版的IENodejs 0.10+支持
  • 2.该方法提供的回调函数会在浏览器完成后面的其他语句后立即执行

关于第一点非常好理解,我自己也做过尝试,确实只有IE10以及更高的版本才能使用;

而第二点说的有点含糊,我个人理解为setImmediate的回调应该是在JS引擎执行完所有的同步代码以后立即执行的。

那不管如何理解,我们在IE浏览器中试试应该能得出更准确的结论。

以下所有的示例均在IE11中进行测试

示例一

首先是一个最简单的示例:

console.log('script start');
setImmediate(function(){
    console.log('宏任务执行:setImmediate');
})
console.log('script end');

这段代码的输出顺序如下:

从这个示例的结果可以看到setImmediate的回调函数确实是在同步代码执行完成后才执行的。这个结果能说明前面的理解是正确的吗?

先不要着急,我们在来看一个示例。

示例二

console.log('script start');
setImmediate(function(){
    console.log('宏任务执行:setImmediate');
})
setTimeout(function(){
    console.log('宏任务执行:setTimeout 0');
},0)
console.log('script end');

在这个示例中,我们写了一个setTimeout定时器,并且将时间设置为0。根据代码书写顺序,在将setImmediate推入宏任务队列以后,紧接着setTimeout的回调也会被推入宏任务队列,所以最终应该输出:

script start
script end
宏任务执行:setImmediate
宏任务执行:setTimeout 0

但是浏览器的输出结果并不是这样的:

示例三

console.log('script start');
setTimeout(function(){
    setImmediate(function(){
        console.log('宏任务执行:setImmediate');
    })
    setTimeout(function(){
        console.log('宏任务执行:setTimeout 0');
    },0)  
}, 0)

console.log('script end');

在这个例子中,我们将setImmediatesetTimeout 0是嵌套在异步任务setTimeout的里面,并且外层的setTimeout设置的时间是0ms

然而令人困惑的是这段代码在IE浏览器中的输出结果是不确定的:

以上是多次刷新页面的输出结果

经过以上三个示例,关于setImmediatesetTimeout 0两者的执行时机貌似得不出什么合适的结论,所以这个问题先不做总结,后续在研究吧~

setTimeout 0 和setTimeout 1

在学习这个的时候看到一个特别有意思的代码:

setTimeout(function(){
    console.log('宏任务执行:setTimeout 1ms');
}, 1)

setTimeout(function(){
    console.log('宏任务执行:setTimeout 0ms');
}, 0)

如果按照事件循环机制的说法,理论上以上的代码输出结果为:

script start
script end
宏任务执行:setTimeout 0
宏任务执行:setTimeout 1

这段代码在FirefoxIE中确实是前面我们推测出来的结果:

但是在Chrome中却是下面这样的结果:

Chrome浏览器的输出结果貌似有点违背前面总结的事件循环机制,但是实际上并没有,因为我们有一句非常重要的话:当异步任务到达触发条件以后将异步任务的回调函数推入任务队列

所以对于setTimeout 1是在1ms后将回调函数推入任务队列setTimeout 0则是立即将回调函数推入任务队列,然而setTimeout 1setTimeout 0的前面,执行完setTimeout 1以后,当执行setTimeout 0的时候1ms的时间已经过去了,那这个时候setTimeout 1的回调函数就比setTimeout 0的回调函数先压入任务队列,所以就会出现Chrome中的打印结果。

Ajax和Dom事件的疑惑

前面我们在对JS中的任务分类时,对AjaxDom事件并没有进行归类,一个原因是发现很多文章并没有对这两个任务进行分类,也有很多文章对这两个任务的分类都不一致;另外一个原因就是我自己也没有找到一些合适的例子去证实。

不过关于事件循环机制 HTML Standard有关于 Event Loop 的介绍,不过介于全篇是纯英文的,简单看过之后只get到了下面的这些有效信息:

在经过翻译和解读以后,得出来下面这些信息。

每一个任务都有相关的任务源

function fn(){ }
setTimeout(fn, 1000)

在上面的例子中fn就称为是setTimeout回调函数setTimeout就称为该回调函数的任务源。

推入任务队列的是对应的回调函数,执行回调函数的时候可以称为在执行任务。所以在该示例中就可以说任务fn对应的任务源就是setTimeout

浏览器会根据任务源去分类所有的任务

这个就是前面我们第二节中总结的JavaScript中任务的分类

浏览器有一个用于鼠标和按键事件的任务队列

关于这个说法的完整翻译为:浏览器可以有一个用于鼠标和按键事件的任务队列(与用户交互任务源关联),另一个与所有其他任务源关联。然后,使用在事件循环处理模型的初始步骤中授予的自由度,它可以使键盘和鼠标事件优先于其他任务四分之三的时间,从而保持界面的响应性,但不会耗尽其他任务队列

看完这段话,我会理解DOM事件是不是区别于前面我们说的宏任务微任务

总而言之呢,关于AjaxDom事件到底是属于微任务还是宏任务?以及它们两个和其他异步任务共同存在时的执行顺序,我自己还是存疑的,所以就不给出什么结论了,以免误导大家。当然如果大家有明确的结论或者示例,欢迎提出来~

总结

到此本篇文章就结束了,有关浏览器中的事件循环机制就我们总结的两个核心点:JavaScript任务分类任务执行顺序

只要牢牢掌握这两点,就能解决大部分问题。

然而本篇文章还遗留了两个问题:

  • 浏览器中setTimeout 0setImmediate执行顺序
  • ajaxdom事件是宏任务还是微任务

年后有时间在复盘总结这两个问题吧。

最后提前祝大家在新的一年好运哦~

近期文章

详解Vue中的computed和watch

记一次真实的Webpack优化经历

JavaScript的执行上下文,真没你想的那么难

写在最后

如果这篇文章有帮助到你,❤️关注+点赞❤️鼓励一下作者

文章公众号首发,关注 不知名宝藏程序媛 第一时间获取最新的文章

笔芯❤️~

posted @ 2021-02-07 15:43  小土豆biubiubiu  阅读(1906)  评论(2编辑  收藏  举报