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

267 阅读4分钟

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

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

举个例子

看下面这个例子:

<!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 事件更高

看实验代码:

<!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 事件是同步的原理,在这篇知乎有讲。