当 Event Loop 遇上事件冒泡

2,752 阅读5分钟

目录

  1. 前置知识
  2. 浏览器的事件循环机制
  3. 当 Event Loop 遇上事件冒泡

要理解事件循环机制, 要先理解一些前置知识

一. 前置知识

6

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()

7

4. JS 中的任务队列

前面提到了队列的概念, 在 JS 中, 有专门的任务队列用来存放一些待执行的任务, 这些任务会被通过一些指定的顺序推入调用栈中执行。
任务队列又分宏任务队列和微任务队列,

宏任务

setTimeout、setInterval、script(整个 js 文件代码)、setImmediate。

其中,setImmediate 优先级比 setTimeout 和 定时器高,因为定时器有最小延迟时间,而 setImmediate 是马上将任务放进宏任务队列中。但可惜,setImmediate 只在 nodeIE 环境有。

微任务

Promise 中的 .then, MutationObserver(html5新特性,用于监听 dom 变化)

二. 浏览器的事件循环机制

  1. 最开始, 执行栈为空, 微任务队列为空, 宏任务队列有一个 script 标签(内含整体代码)
  2. 将第一个宏任务出队, 这里即为上述的 script 标签
  3. 整体代码执行过程中, 如果是同步代码, 直接执行(函数执行的话会有入栈出栈操作), 如果是异步代码, 会根据任务类型推入不同的任务队列中(宏任务或微任务)
  4. 当执行栈执行完为空时, 会去处理微任务队列的任务, 将微任务队列的任务一个个推入调用栈执行完
  5. 微任务执行完后,检查是否需要重新渲染 UI。
  6. ...往返循环直到宏任务和微任务队列为空

总结一下上述循环机制的特点:
出队一个宏任务 -> 调用栈为空后, 执行一队微任务 -> 更新界面渲染 -> 回到第一步

上面都是空谈概念, 现在让我们看一段代码:

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)

分析一下这个循环过程:

  1. script 标签(整体代码)入栈
  2. 执行整体代码过程中先后将异步任务 Promise.resolve().thensetTimeout 放入微任务和宏任务队列中
  3. 执行微任务队列的所有任务, 打印 "Promise1", 又将里面的异步任务 setTimeout 推入宏任务队列中
  4. 取出第一个宏任务推入栈执行, 打印出 "setTimeout1", 同时将内部的异步任务 promise.resolve().then 推入微任务队列
  5. 执行微任务队列中的所有任务, 先后打印出 "Promise2" "Promise3"
  6. 执行最后一个宏任务, 打印出 "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 周期里先后得到执行。

上述代码在线demo

但事件还没那么简单,我们来看另一版代码:

一样的 HTML,一样的 JS 事件监听,唯一不同的是,这次我们用代码触发 click 事件

// ...
inner.click()

打印结果为:

"click"
"promise"
"click"
"promise"
"timeout"
"timeout"

依旧分析一下:
(0)将 script(宏任务)放入执行栈执行,执行到 inner.click() 的时候,执行 onClick 函数,打印 "click"

(1)当执行完 onClick 后,此时的 script(宏任务)还没返回,执行栈不为空,不会去清空微任务,而是会将事件往上冒泡派发

...(关键步骤分析完后,续步骤就不分析了)

上述代码在线 demo

小结一下:

在一般情况下,微任务的优先级是更高的,是会优先于事件冒泡的,但如果手动 .click() 会使得在 script代码块 还没弹出执行栈的时候,触发事件派发。

参考: Tasks, microtasks, queues and schedules