之前写过一篇 浏览器事件循环的文章。今天又忽然对 dom 事件这个点有点疑惑,于是做实验重新写一盘。
我们都知道“微任务”要比“宏任务”具有更高优先级,微任务队列要一次性清空后,才能轮得到宏任务队列的清空。于是代码中同时 Promise 状态变化的回调总是要比 setTimeout 这类事件的回调更早触发。
然而,假设 2 个事件都是宏任务事件的话,他们真的是以“谁先触发就先执行谁的回调”这样的原则来处理吗?实际测试发现宏任务中貌似也有着不同优先级的队列,例如“DOM 事件”总是要比“setTimeout 事件”要更早的触发回调。
举个例子
看下面这个例子:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 
 | <!DOCTYPE html><html lang="en">
 <head>
 <meta charset="UTF-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 <meta http-equiv="X-UA-Compatible" content="ie=edge" />
 <title>Document</title>
 </head>
 <body>
 <div>
 <button id="start" onclick="start()">开始</button>
 <button id="testBtn" onclick="test()">测试按钮</button>
 </div>
 <script>
 function start() {
 // 开始后,立刻推送900ms倒计时逻辑
 setTimeout(function () {
 console.log("倒计时timer handler执行");
 }, 900);
 // 接下来立刻卡住线程
 var startTime = new Date();
 while (new Date().getTime() - startTime < 5000) {}
 // 实验 1:在线程卡住开始后, 900 毫秒倒计时之内,你点一下test b按钮
 // 实验 2:在线程卡住开始后, 900 毫秒倒计时之外,你点一下test b按钮
 }
 
 function test() {
 console.log("测试按钮回调函数执行");
 }
 
 // 结果分析
 // 1. timer本应该 900 毫秒后就执行,结果他被卡到5 秒后再执行。 这个是因为 js 单线程机制,很容易理解。
 // 2. 你在第900毫秒时候有个宏任务 setTimeout 发生,你在第3秒的时候,点了一下“测试按钮”,这个 dom 点击宏任务理论上也会推入宏队列队列中。 这俩事件都是等待 js 空闲时候调用,最终你都会发现:即使测试按钮点击的比timer 晚,他依然永远都是 “测试按钮的回调” 先执行。
 // 3. 这是否说明 dom event 宏任务,要比 timer 宏任务队列具有更高优先级?
 </script>
 </body>
 </html>
 
 | 
运行后,界面上会有 2 个按钮:

- 当你点击开始后,js 线程会设置一个 900 毫秒定时器,并卡死线程 5 秒。
- 当你在卡死的五秒钟内,点击“测试按钮”。最后等待五秒。会发现你的 dom 事件回调永远要比 setTimeout 回调要更早。
第一次实验结论
基于此,我们初步推测:宏任务中也有队列优先级。
即“DOM 事件”的优先级要比其他宏任务队列优先级高,因此 js 空闲后会优先去清空“DOM 事件”队列。猜测可能是出于提高页面响应性的目标而做出此种决策。
那么 dom 事件跟微任务相比呢
结论是:微任务优先级要比 dom 事件更高
看实验代码:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 
 | <!DOCTYPE html><html lang="en">
 <head> </head>
 <body>
 <div>
 <button id="start" onclick="start()">开始</button>
 <button id="testBtn" onclick="test()">测试按钮</button>
 </div>
 <script>
 function start() {
 Promise.resolve().then((res) => {
 console.log("Promnise 微任务1执行");
 });
 
 var startTime = new Date();
 while (new Date().getTime() - startTime < 5000) {}
 
 Promise.resolve().then(() => {
 console.log("promise 微任务 2");
 });
 }
 
 function test() {
 console.log("测试按钮回调函数执行");
 }
 
 
 
 </script>
 </body>
 </html>
 
 | 
经测试,打印顺序为:promise 微任务 1—-promise 微任务 2—-测试按钮点击。
也就是说,即使微任务注册的比你 click 时间晚,微任务队列依然要先清空后,才能轮到执行你的 click 回调。
至于原因:如果我们认为“dom event”就是宏任务,那么该实验结果是符合预期的。毕竟 js 线程空闲后,要先把之前注册过的微任务清空,再执行宏任务。这是我们过往从互联网上学习过的经验结论。
那么 dom 事件到底是什么任务?
- 由上述 Promise 的对比实验可知,dom event 属于宏任务
- 由上文最开始的 setTimeout 实验对比可知,dom Event 比 setTimeout 更早触发,所以 dom 事件又像是微任务。
综合结论:dom event 我们可以认为是一种 “比其他宏任务优先级更高”的宏任务。这或许是浏览器为了提高响应流畅度而设计的策略。
其他
注意:大家做 dom event 实验时,要避免使用编程方式触发 event(例如 dispatchEvent 方式),而要自己用鼠标去点。 因为 dispatchEvent 触发 dom 事件是同步的,同步的触发肯定永远比任何宏任务微任务都要快。 关于 dispatchEvent 这种方式触发的 dom 事件是同步的原理,在这篇知乎有讲。