剖析setTimeout和click点击事件的触发顺序
下面是一段非常简单的 JavaScript 代码
1 | <div> |
但是当你点击这个按钮时,产生的效果可能会让你有些困惑。下面我们来看下:
页面打开 2s 内点击一次按钮
这段 JavaScript 代码当你在页面打开 2s 时间内点击一次按钮,效果是这样的:
- 页面卡住大约 5s 钟
- 大约 5s 后弹出
click handler - 点击弹窗确认后,弹出
timer handler
当你继续再次点击页面中的按钮,此时依次发生:
- 页面卡住大约 5s 钟
- 大约 5s 后弹出
click handler一次! - 点击确认后又弹出
click handler一次。
如果继续点击 button 按钮,会出现同样的效果,且 click handler 弹出的次数会依次增加
分析
分析这段代码来看,点击按钮后,代码执行会进入 test 函数, test 函数中首先对 document 对象绑定上了一个 click 事件。然后执行了一个 5s 的死循环。
此时页面卡住就是因为这个死循环
1 | ar startTime = new Date() |
这个死循环会导致 js 阻塞在这里. 在这 5s 时间内,2s 的定时器其实在第 2 秒的时候已经定时完成,并把这个完成的事件放入到了任务队列中;而你在 2 秒之前点击的按钮这个 click 事件也被浏览器放入一个 dom 事件队列等待执行。
当 5s 死循环的时间过去,js 引擎开始变成空闲,此时点击按钮触发的这个 test 处理器执行完毕,js 引擎便从事件队列中取出
click事件进行执行,当前元素没有订阅 click 那么就冒泡到订阅了该事件的 document 进行执行。(会继续冒泡到 document。(本质上冒泡其实是: 浏览器取出 dom 事件中的 click 事件,然后从 target 元素开始往上找 看下是否整个网页中还有元素订阅了这个 click 事件)由于在刚刚 test 函数执行期间,document 对象上绑定上了
click的监听,所以此时冒泡上来的click会触发 document 对象上的click事件处理器, 因此弹出了click handler.当这个
click冒泡完毕,所有的订阅者订阅的处理器都被完全处理完,js 线程再次空闲,此时去查看任务队列中的任务,发现有个 2s 定时器的任务已经执行完毕,js 开始执行定时器的回调函数,所以弹出了time handler当你第二次点击按钮,再次触发了 test 函数。 此时 test 函数内还是做了同样的事情,但是之前 document 上已经绑定了一个 click 的 handler 函数,所以第二次执行 test 函数,会让
document对象的 click 处理器变成 2 个。 因此第二次点击按钮click handler会弹出 2 次
页面打开 2s 内点击一次按钮,然后第 3s 时点击页面空白处 2 次
这样操作的效果是这样的:
- 页面卡住大约 5s 钟
- 大约 5s 后弹出 ‘click handler’
- 弹出 ‘click handler’ 第二次
- 弹出 ‘click handler’ 第三次
- 弹出 ‘time handler’
分析
第 2 点之所以出现在第 5 点之前,在上文我们已经讲过原因了—总之,基本上是因为 click 触发的时刻确实就比 timer 触发的早,肯定要等 click 的 handler 都处理完再执行 timer 处理器。
但至于第 3、4 点为什么出现在 5 之前呢?这个跟 2 出现在 5 之前的原因就不一样了,因为用户在页面上的第二次和第三次点击是在 2s 钟之后了,此时 timer 定时器肯定已经完成了,但是触发 click handler 依然在 timer handler 之前。 这是为什么呢?
这主要是因为 js 获取任务来执行时, 点击事件的任务队列 要优先于 timer事件的任务队列。 具体可参考我的另外一篇文章 浏览器的单线程机制和事件循环
在页面卡住的 5s 时间内,用户在页面上点击的 2 次事件会放入比 timer 更优先的一个 macroTask 任务队列。由于 js 空闲时优先要把 click 事件这种更优先的 macroTask 任务执行完,直到任务队列为空。所以就出现了上面 click handler 要比 timer handler 更早弹出的效果。
心得
js 中事件可以注册多个 handler 形成 handlers. handlers 类似于一个处理器的数组。事件触发后,该事件的 handler 处理器会被依次执行.
这里举个跟上面有点区别的例子:假如在某个 handler 执行的过程中,又给该事件增加了新的 handler,那么新增的这个 handler 不能立即执行。demo 测试代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<div>
<button id="test" onclick="test()">dianji</button>
</div>
<script>
function test() {
alert("click handler 1");
/* test函数触发的过程中,又给按钮绑定了新的handler; 但本次handlers遍历执行的过程中,不会执行新加入的这个handler */
/* 因此,首次点击按钮, click handler2 不会弹出 */
document.querySelector("#test").addEventListener(
"click",
function (e) {
alert("click handler 2");
},
false
);
}
</script>其实这里原理很简单:因为 test 元素对象上的事件 handlers 被触发执行的时候,类似于把数组拿出来遍历。你不可能把遍历数组和修改数组的逻辑同时运行。如:
1
2
3
4
5
6
7
8
9
10
11
12let a = [1, 2, 3];
let count = "x";
a.forEach((item, index) => {
console.log(item);
a.push(count + index);
});
console.log(a);
// 输出
// 1
// 2
// 3
// [ 1, 2, 3, 'x0', 'x1', 'x2' ]除非你 addEventListener 的时候,添加到冒泡的上层元素上。即下面讲的第三点。
addEventListener会给事件不断增加新的处理器 handler事件处理器 handler 在执行期间,事件还没有冒泡。此时还有机会给上层元素绑定事件处理器。
一个事件在冒泡过程中,要等所有订阅该事件的处理器都处理完毕,js 才会去选择新的任务队列中的任务来执行。在事件触发后以及事件的冒泡过程中,会优先执行订阅了该冒泡事件的处理器,而不会去理会任务队列。
这一条原理很简单,只需知道:js 在执行同一个 dom 事件的所有回调处理器的过程是同步的,占用 js 线程执行的即可。
在事件循环中 microTask 优先于 marcroTask 执行,且 macroTask 中也有不同优先级的队列,例如 dom 事件便高于 timer。