下面是一段非常简单的 JavaScript 代码
<div>
<button onclick="test()">dianji</button>
</div>
<script>
setTimeout(function () {alert('timer handler')
}, 2000)
function test () {document.addEventListener('click', function (e) {alert('click handler')
}, false)
var startTime = new Date()
while ((new Date()).getTime() - startTime < 5000){}}
</script>
但是当你点击这个按钮时,产生的效果可能会让你有些困惑。下面我们来看下:
页面打开 2s 内点击一次按钮
这段 JavaScript 代码当你在页面打开 2s 时间内点击一次按钮,效果是这样的:
- 页面卡住大约 5s 钟
- 大约 5s 后弹出
click handler
- 点击弹窗确认后,弹出
timer handler
当你继续再次点击页面中的按钮,此时依次发生:
- 页面卡住大约 5s 钟
- 大约 5s 后弹出
click handler
一次! - 点击确认后又弹出
click handler
一次。
如果继续点击 button 按钮,会出现同样的效果,且 click handler
弹出的次数会依次增加
分析
分析这段代码来看,点击按钮后,代码执行会进入 test
函数, test 函数中首先对 document
对象绑定上了一个 click
事件。然后执行了一个 5s 的死循环。
此时页面卡住就是因为这个死循环
ar startTime = new Date()
while ((new Date()).getTime() - startTime < 5000){}
这个死循环会导致 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 测试代码:
<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 被触发执行的时候,类似于把数组拿出来遍历。你不可能把遍历数组和修改数组的逻辑同时运行。如:
let 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。