目录
- 前置知识
- 浏览器的事件循环机制
- 当 Event Loop 遇上事件冒泡
要理解事件循环机制, 要先理解一些前置知识
一. 前置知识

1. 栈
栈是一种后进先出的数据结构, 栈只支持对栈顶进行数据的插入和删除。
你可以想象成一沓书, 先放的被压在底下, 后放的可以最先拿出来。
2. 队列
队列是一种先进先出的数据结构, 队列只支持对队尾进行数据插入, 对对头进行数据删除。
可以想象成排队, 先排队的人可以先完成并离开, 同时只能从队的尾巴进行排队。
3. 浏览器的调用栈
JS 有一个调用栈, 也叫执行栈。当函数执行的时候, 会将函数推入调用栈中, JS 主线程会执行调用栈顶的代码, 当函数执行完毕后出栈。
来看一段代码:
function a(){
console.log('a')
return b()
}
function b(){
console.log('b')
return c()
}
function c(){
console.log('c')
}
a()

4. JS 中的任务队列
前面提到了队列的概念, 在 JS 中, 有专门的任务队列用来存放一些待执行的任务, 这些任务会被通过一些指定的顺序推入调用栈中执行。
任务队列又分宏任务队列和微任务队列,
宏任务
setTimeout、setInterval、script(整个 js 文件代码)、setImmediate。
其中,setImmediate 优先级比 setTimeout 和 定时器高,因为定时器有最小延迟时间,而 setImmediate 是马上将任务放进宏任务队列中。但可惜,setImmediate 只在 node 和 IE 环境有。
微任务
Promise 中的 .then, MutationObserver(html5新特性,用于监听 dom 变化)
二. 浏览器的事件循环机制
- 最开始, 执行栈为空, 微任务队列为空, 宏任务队列有一个 script 标签(内含整体代码)
- 将第一个宏任务出队, 这里即为上述的 script 标签
- 整体代码执行过程中, 如果是同步代码, 直接执行(函数执行的话会有入栈出栈操作), 如果是异步代码, 会根据任务类型推入不同的任务队列中(宏任务或微任务)
- 当执行栈执行完为空时, 会去处理微任务队列的任务, 将微任务队列的任务一个个推入调用栈执行完
- 微任务执行完后,检查是否需要重新渲染 UI。
- ...往返循环直到宏任务和微任务队列为空
总结一下上述循环机制的特点:
出队一个宏任务 -> 调用栈为空后, 执行一队微任务 -> 更新界面渲染 -> 回到第一步
上面都是空谈概念, 现在让我们看一段代码:
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
})
setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
Promise.resolve().then(()=>{
console.log('Promise3')
})
},0)
分析一下这个循环过程:
- script 标签(整体代码)入栈
- 执行整体代码过程中先后将异步任务
Promise.resolve().then和setTimeout放入微任务和宏任务队列中 - 执行微任务队列的所有任务, 打印
"Promise1", 又将里面的异步任务setTimeout推入宏任务队列中 - 取出第一个宏任务推入栈执行, 打印出
"setTimeout1", 同时将内部的异步任务promise.resolve().then推入微任务队列 - 执行微任务队列中的所有任务, 先后打印出
"Promise2" "Promise3" - 执行最后一个宏任务, 打印出
"setTimeout2"
三、当 Event Loop 遇上事件冒泡
上面我们已经讲完了 Event Loop 机制,但当 Event Loop 遇上事件冒泡,又会怎么不一样呢?
代码如下:
<div class="outer">
<div class="inner"></div>
</div>
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
}
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
点击 inner,最终打印结果为:
"click"
"promise"
"click"
"promise"
"timeout"
"timeout"
为什么打印结果是这样的呢?我们来分析一下:
(0)将 script 标签内的代码(宏任务)放入执行栈执行,执行完后,宏任务微任务队列皆空。
(1)点击 inner,onClick 函数入执行栈执行,打印 "click"。执行完后执行栈为空,因为事件冒泡的缘故,事件触发线程会将向上派发事件的任务放入宏任务队列。
(2)遇到 setTimeout,在最小延迟时间后,将回调放入宏任务队列。遇到 promise,将 then 的任务放进微任务队列
(3)此时,执行栈再次为空。开始清空微任务,打印 "promise"
(4)此时,执行栈再次为空。从宏任务队列拿出一个任务执行,即前面提到的派发事件的任务,也就是冒泡。
(5)事件冒泡到 outer,执行回调,重复上述 "click"、"promise" 的打印过程。
(6)从宏任务队列取任务执行,这时我们的宏任务队列已经累计了两个 setTimeout 的回调了,所以他们会在两个 Event Loop 周期里先后得到执行。
但事件还没那么简单,我们来看另一版代码:
一样的 HTML,一样的 JS 事件监听,唯一不同的是,这次我们用代码触发 click 事件
// ...
inner.click()
打印结果为:
"click"
"promise"
"click"
"promise"
"timeout"
"timeout"
依旧分析一下:
(0)将 script(宏任务)放入执行栈执行,执行到 inner.click() 的时候,执行 onClick 函数,打印 "click"
(1)当执行完 onClick 后,此时的 script(宏任务)还没返回,执行栈不为空,不会去清空微任务,而是会将事件往上冒泡派发
...(关键步骤分析完后,续步骤就不分析了)
小结一下:
在一般情况下,微任务的优先级是更高的,是会优先于事件冒泡的,但如果手动 .click() 会使得在 script代码块 还没弹出执行栈的时候,触发事件派发。