Blog
Blog copied to clipboard
浏览器中的事件循环
前言
js的一个特点是单线程,即浏览器中js引擎中负责解析执行js代码的线程只有一个。这是因为在浏览器环境中,我们常常需要对DOM做各种各样的操作。假设js是多线程的,那么当两个js线程同时对一个DOM进行一项操作,比如线程A希望删除这个DOM,而线程B希望改变其样式,这时就涉及到了复杂的同步问题。因此,为了保证不发生和上述场景类似的问题,js的执行只由一个线程完成。
而js中有许多原生的异步事件,诸如 setTimeout,setInterval,事件监听,Ajax请求等等。那么单线程的js是如何实现异步的呢?其核心在于js的事件循环机制。
宏任务、微任务
每个js线程拥有独立的Event loop,大多数的代码会依据正常的函数调用规则来执行,而遇到特殊的任务源,如 setTimeout/setInterval 则由他们将不同的任务分发到对应的任务队列中。
任务又分为宏任务(Macrotask) 与 微任务(Microtask) 两种。在浏览器中, 宏任务:包括主代码块,setTimeout/setInterval回调,I/O,UI Rendering等; 如有必要,浏览器会在一个宏任务完成之后,下一个宏任务开始之前,重新渲染页面。 微任务:包括promise回调,MutationObserver回调。
事件循环过程
事件循环的过程可以用下图来表示,概括起来,事件循环的一轮迭代主要包括3个步骤:
- 从Macrotask队列中取出一个任务执行至结束;
- 将Microtask队列中的任务依次取出并执行,直到Microtask队列为空;
- 如果浏览器需要渲染,则重新渲染。

Case Study 1
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
首先,Macrotask队列中主代码块最先被执行,这里会首先输出 script start ,遇到 setTimeout 会将它的回调函数分发到Macrotask队列中,然后继续执行,遇到 Promise ,再将 Promise 的两个回调函数依次分发到Microtask队列中,接着向下执行输出 script end ,此时,主代码块执行结束。开始处理Microtask队列中的任务,即promise的两个回调函数,所以控制台接着输出 promise1 promise2 ,到目前为止Microtask队列为空,一轮事件循环完成。
然后开始第二轮事件循环,从Macrotask队列中取出setTimeout回调并执行,控制台输出 setTimeout ,Microtask队列仍为空,第二轮事件循环结束。至此,程序运行完毕,控制台的所有输出汇总如下:
script start
script end
promise1
promise2
setTimeout
Case Study 2
我们再来看一个涉及到html 的例子,我们创建两个div,inner div嵌套在outer div里面,代码如下:
<div class="outer">
<div class="inner"></div>
</div>
// 首先获取文档中的两个元素
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
// 创建MutationObserver监听outer div的属性改化,如果发生变化就调用函数
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
});
// 声明一个事件监听器
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
outer.setAttribute('data-random', Math.random());
}
// 将监听器分别和两个div绑定
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
那么现在我们点击内部的div,控制台会如何输出呢?
首先,点击inner div相当于一次I/O事件,会触发inner div绑定的 onClick 函数 ,同时由于event bubble也会触发和outer div绑定的 onClick 函数,两个onClick函数作为Macrotask分发至Macrotask队列中。
接着,从Macrotask中取出onClick函数执行,接下来就和上一个例子差不多了,控制台输出 click,setTimeout 将其回调函数分发至Macrotask队列,Promise将其回调函数分发至Microtask队列,然后执行到outer.setAttribute,注意它修改了outer div的属性,会触发MutationObserver绑定的回调函数,而该回调函数会分发至Microtask队列中。至此,inner div的事件监听器执行完毕。
此时,Macrotask队列和Microtask的情况分别是:
Macrotask队列:onClick | setTimeout callback
Microtask队列:Promise callback | Mutation observer
然后,开始处理所有的Microtask,控制台依次输出promise mutate,一轮事件循环结束。
此时,Macrotask队列和Microtask的情况分别是:
Macrotask队列:onClick | setTimeout callback
Microtask队列:空
第二轮事件循环开始执行第二个onClick函数,和上一个一样,控制台输出 click,setTimeout 将其回调函数分发至Macrotask队列,Promise将其回调函数分发至Microtask队列,然后outer.setAttribute 触发MutationObserver绑定的回调函数,而该回调函数会分发至Microtask队列中。
此时,Macrotask队列和Microtask的情况分别是:
Macrotask队列:setTimeout callback | setTimeout callback
Microtask队列:Promise callback | Mutation observer
然后,开始处理所有的Microtask,控制台依次输出promise mutate,第二轮事件循环结束。
此时,Macrotask队列和Microtask的情况分别是:
Macrotask队列:setTimeout callback | setTimeout callback
Microtask队列:空
接着继续执行setTimeout的回调函数,控制台输出timeout timeout。至此,程序运行完毕,控制台的所有输出汇总如下:
click
promise
mutate
click
promise
mutate
timeout
timeout
参考资料
Tasks, microtasks, queues and schedules 深入理解js事件循环机制(浏览器篇) 《JavaScript核心技术开发揭秘》
想到还有种场景,如果通过inner.click()来触发事件的话,console的输出顺序会是什么呢😉