关于前端dom事件回调的任务优先级问题

之前写过一篇 浏览器事件循环的文章。今天又忽然对 dom 事件这个点有点疑惑,于是做实验重新写一盘。

我们都知道“微任务”要比“宏任务”具有更高优先级,微任务队列要一次性清空后,才能轮得到宏任务队列的清空。于是代码中同时 Promise 状态变化的回调总是要比 setTimeout 这类事件的回调更早触发。

然而,假设 2 个事件都是宏任务事件的话,他们真的是以“谁先触发就先执行谁的回调”这样的原则来处理吗?实际测试发现宏任务中貌似也有着不同优先级的队列,例如“DOM 事件”总是要比“setTimeout 事件”要更早的触发回调。

举个例子

看下面这个例子:

1
2
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 个按钮:

image.png

  1. 当你点击开始后,js 线程会设置一个 900 毫秒定时器,并卡死线程 5 秒。
  2. 当你在卡死的五秒钟内,点击“测试按钮”。最后等待五秒。会发现你的 dom 事件回调永远要比 setTimeout 回调要更早。

第一次实验结论

基于此,我们初步推测:宏任务中也有队列优先级。

即“DOM 事件”的优先级要比其他宏任务队列优先级高,因此 js 空闲后会优先去清空“DOM 事件”队列。猜测可能是出于提高页面响应性的目标而做出此种决策。

那么 dom 事件跟微任务相比呢

结论是:微任务优先级要比 dom 事件更高

看实验代码:

1
2
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("测试按钮回调函数执行");
}
// 实验结果显示:微任务总是会在我 click 之前触发。哪怕我 click 明显要比 Promise的微任务注册要早。
// 打印顺序为:promise 微任务 1----promise 微任务 2----测试按钮点击
// 具体原理看我的博客吧。只能说 dom event 比较特殊,可能是优先级比较高的宏任务。
</script>
</body>
</html>

经测试,打印顺序为:promise 微任务 1—-promise 微任务 2—-测试按钮点击。

也就是说,即使微任务注册的比你 click 时间晚,微任务队列依然要先清空后,才能轮到执行你的 click 回调。

至于原因:如果我们认为“dom event”就是宏任务,那么该实验结果是符合预期的。毕竟 js 线程空闲后,要先把之前注册过的微任务清空,再执行宏任务。这是我们过往从互联网上学习过的经验结论。

那么 dom 事件到底是什么任务?

  1. 由上述 Promise 的对比实验可知,dom event 属于宏任务
  2. 由上文最开始的 setTimeout 实验对比可知,dom Event 比 setTimeout 更早触发,所以 dom 事件又像是微任务。

综合结论:dom event 我们可以认为是一种 “比其他宏任务优先级更高”的宏任务。这或许是浏览器为了提高响应流畅度而设计的策略。

其他

注意:大家做 dom event 实验时,要避免使用编程方式触发 event(例如 dispatchEvent 方式),而要自己用鼠标去点。 因为 dispatchEvent 触发 dom 事件是同步的,同步的触发肯定永远比任何宏任务微任务都要快。 关于 dispatchEvent 这种方式触发的 dom 事件是同步的原理,在这篇知乎有讲。