最近阅读了一些与浏览器事件循环相关的资料,并整理输出了本篇文章,希望能帮助大家搞懂浏览器的事件循环。后续(会很久,因为我懒)会继续补充node中的事件循环机制。
文章将从三个部分介绍事件循环机制:
1.消息队列和事件循环;
2.宏任务和微任务;
3.一道经典笔试题;
消息队列和事件循环
JavaScript语言的一大特点就是单线程,作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。于是,所有任务可以分成两种,一种是同步任务,另一种是异步任务。同步任务指的是,在主线程上排队执行的任务;异步任务指的是,不进入主线程、而进入"消息队列"的任务。
每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。为了协调这些任务有条不紊地在主线程上执行,页面进程引入了事件循环机制和消息队列。
页面的事件循环系统如下图:
渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务添加进消息队列尾部。
消息队列存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。通过使用消息队列,实现了线程之间的消息通信。
渲染主线程会循环地从消息队列头部中读取来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JavaScript 脚本来处理该点击事件。
宏任务和微任务
按照异步任务的优先级不同,消息队列还分为宏任务队列和微任务队列,微任务队列优先级高。
页面中的大部分任务都是在主线程上执行的,这些任务包括了:渲染事件(如解析 DOM、计算布局、绘制);用户交互事件(如鼠标点击、滚动页面、放大缩小等);JavaScript 脚本执行事件;网络请求完成、文件读写完成事件。
通常我们把消息队列中的任务称为宏任务,宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,因为页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。
为了直观理解,可以看下面这段代码,在这段代码中,是想通过 setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,因为如果这两个任务的中间插入了其他的任务,就很有可能会影响到第二个定时器的执行时间了。
- test
打开控制台的Performance面板,可以看到这段代码的执行过程。如下图:两个setTimeout 函数触发的回调函数中间被插入了其它的Tack。所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了。
在理解了宏任务之后,下面我们就可以来看看什么是微任务了。
微任务是一个需要异步执行的函数,执行时机是在当前宏任务结束时。
我们知道当 JavaScript 执行一段脚本的时候,JavaScript引擎会为其创建一个全局执行上下文,在创建全局执行上下文的同时,引擎也会在内部创建一个微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。
微任务是怎么产生的?在现代浏览器里面,产生微任务有两种方式。第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。
微任务队列是何时被执行的?通常情况下,一次事件循环中,在当前宏任务中的 JavaScript 执行完成时,会有一个单独的步骤,叫 Perform a microtask checkpoint,即执行微任务检查点。这个操作是检查微任务队列中是否有微任务,如果有,然后按照顺序执行队列中的微任务。如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,JavaScript引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行。
通常对浏览器来说:宏任务:包括script(整体代码), setTimeout, setInterval, I/O, UI rendering等;微任务包括 Promises(这里指浏览器实现的原生 Promise), MutationObserver等。????请尤其注意执行整体代码也是一个宏任务。
了解了宏任务和微任务后,通俗来说, 浏览器端事件循环就是:
从script(整体代码)开始第一次循环,之后全局上下文进入函数调用栈,直到调用栈清空(只剩全局)。然后检查微任务队列是否为空,如果不为空,依次取出微任务队列中的微任务,压入执行栈并执行,当所有可执行的微任务执行完毕之后,就结束了一次事件轮询。循环再次从宏任务开始,然后再执行所有的微任务......一直循环。无论在执行宏任务还是微任务 都有可能产生新的微任务 。
伪代码如下:
while (true) {
宏任务队列.shift()
微任务队列全部任务()
}
一道经典笔试题
纯文字表述确实有点干涩,我们通过一个栗子????,来理解事件循环的具体顺序。编译环境是浏览器标准环境(谷歌webkit内核),不考虑node环境。
一个栗子????:
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('illegalscript start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('promise1')
resolve()
})
.then(function () {
console.log('promise2')
})
async1()
console.log('illegalscript end')
// illegalscript start
// promise1
// async1 start
// async2
// illegalscript end
// promise2
// async1 end
// setTimeout
第一步:
事件循环从宏任务队列开始,这个时候,宏任务队列中,只有一个script(整体代码)任务,script任务开始执行,全局上下文入栈,打印出“illegalscript start”。如图1-1所示。
1-1
第二步:
script任务执行时遇到了setTimeout,setTimeout是一个宏任务,进入宏任务队列,如图2-1所示。
2-1
第三步:
script执行时遇到Promise实例。Promise构造函数中的第一个参数,是在new的时候执行,因此不会进入任何其他的队列,而是直接当同步任务执行了,打印出“promise1”,如图3-1所示。promise构造函数执行完,resolve执行完,promise的then进入微任务队列,如图3-2所示。
3-1 执行promise 的构造函数
3-2 promise的then进入微任务队列
第四步:
执行async1(),根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。当执行到await async2()时,会默认创建一个 Promise 对象,async1可以改写成如下代码,与第三步同理先打印出“async1 start”,如图4-1。执行async2()打印出“async2”,如图4-2。async1、async2出栈,执行await async2()默认创建的promise对象.then进入微任务对应队列,如图4-3。
async1等效于以下代码:
async function async1() {
console.log('async1 start')
new Promise(function (resolve) {
resolve(async2());
}).then(function () {
console.log('async1 end')
})
}
4-1执行async1
4-2 执行async2
4-3 执行await async2()默认创建的promise对象.then进入微任务对应队列。
第五步:
script任务继续往下执行,打印出“illegalscript end”,然后,将运行完的script宏任务从宏任务队列中移除。此时达到执行微任务检查点,去微任务队列,按照先进先出的顺序执行微任务,先执行第一个微任务,打印出"promise2" ,当然,它的执行,也是进入函数调用栈中执行的,如图5-1所示。再执行第二个微任务,打印出“async1 end” ,如图5-2所示。
5-1 执行第一个微任务
5-2 执行第二个微任务
第六步:
当所有的微任务执行完毕之后,表示第一轮的循环就结束了。这个时候就得开始第二轮的循环。第二轮循环仍然从宏任务开始,如图6-1所示。
6-1
第七步:
取出宏任务队列中的setTimeout任务,压入函数调用栈。打印出“setTimeout”,如图7-1所示。
7-1
这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了。
写在最后
写文章时,查了一些资料也请教了一些小伙伴,个人认为关于“微任务的检查点是一次事件循环中,在当前宏任务中的 JavaScript 执行完成时”这个太好理解,补充较为详细的事件循环机制。
选择当前要执行的宏任务队列,选择一个最先进入任务队列的宏任务,如果没有宏任务可以选择,则会跳转至微任务的执行步骤。
将事件循环的当前运行宏任务设置为已选择的宏任务。
运行宏任务。
将事件循环的当前运行任务设置为null。
将运行完的宏任务从宏任务队列中移除。????
微任务步骤:进入微任务检查点。????
更新界面渲染。
返回第一步。
执行进入微任务检查点的具体步骤如下:
设置进入微任务检查点的标志为true。
当事件循环的微任务队列不为空时:选择一个最先进入微任务队列的微任务;设置事件循环的当前运行任务为已选择的微任务;运行微任务;设置事件循环的当前运行任务为null;将运行结束的微任务从微任务队列中移除。
设置进入微任务检查点的标志为false。
到这里,我想表述的事件循环已经说清楚了,能不能理解就看读者老爷们有没有耐心了。
感谢阅读呀~~如果发现有任何问题,欢迎‘打扰’交流呀~~~
扫描二维码
关注更多精彩
爱我就帮我点击“点赞”+“在看”