从一次线上问题聊聊 JS 事件循环

258 阅读10分钟

大家好,我是前端架构师,关注微信公众号【程序员大卫】:

  • 回复 [面试] :免费领取“前端面试大全2025(Vue,React等)”
  • 回复 [架构师] :免费领取“前端精品架构师资料”
  • 回复 [书] :免费领取“前端精品电子书”
  • 回复 [软件] :免费领取“Window和Mac精品安装软件”

背景

JavaScript 的事件循环(Event Loop)机制决定了浏览器如何处理用户操作、异步任务和 UI 更新。很多页面“卡顿”、事件响应延迟等问题,其实都与事件循环和主线程运行方式有关。 本文通过几个小例子,结合流程图,帮助大家直观理解事件循环的实际表现和常见问题。

例1: 按钮点击的完整流程

这是一个最基本的例子,通过它我们可以完整梳理一次“浏览器点击按钮、事件处理和事件循环”背后的整个流程与机制。

<div>
  <button id="btnA">A按钮</button>
</div>
<script>
  btnA.addEventListener("click", () => {
    console.log("点击A按钮");
  });
</script>

一、页面渲染完毕,JS 注册监听器

JS 代码运行时,把事件监听器挂载到 DOM 元素:

btnA.addEventListener("click", () => {
  console.log("点击A按钮");
});

这一步告诉浏览器:“以后 btnA 被点击时,请执行我的回调。”

二、用户物理点击页面上的按钮

用户在浏览器里用鼠标点击了 btnA。

三、浏览器进行命中测试(Hit Testing)

浏览器底层根据鼠标点击的坐标 (x, y),查找页面中哪个元素被点中了。

  • 浏览器考虑当前页面的所有布局、z-index、透明度、pointer-events 等规则。
  • 如果 (x, y) 处是 btnA,那么事件的目标元素就是 btnA。

四、浏览器生成事件对象(MouseEvent)

命中测试后,浏览器已经确定 "这次点击事件的目标元素" 是谁(比如 btnA),这个信息会记录在事件对象(如 MouseEvent)里。

  • 事件对象包含点击类型、目标元素、鼠标坐标等所有细节。
  • 事件对象会进入事件队列。

五、事件对象进入事件队列,等待事件循环(Event Loop)调度

  • 如果主线程在忙(比如在执行一段很耗时的 JS),事件对象只能排队。
  • 事件循环不断检查主线程空闲与否,只有空闲时才会取出队列里的事件对象,并分发到 JS 层

六、事件循环分发事件给目标元素的监听器

  • 浏览器会根据事件对象(MouseEvent)的目标元素(比如 btnA),查找该元素上是否注册了对应类型的监听器(比如 click)。

  • 如果有注册监听器,就依次执行(包括冒泡/捕获阶段),否则就跳过。

七、回调结束,主线程继续空闲,事件循环进入下一轮

附:图解流程

[页面渲染 & 注册监听]
          │
          ▼
JS 注册 btnA 的 click 监听器
          │
          ▼
[用户点击按钮]
          │
          ▼
[浏览器命中测试]
          │
          ├─► (x, y) 命中 btnA?
          │
          ▼
[生成事件对象 MouseEvent]
          │
          ▼
[事件对象进入事件队列]
          │
          ▼
[事件循环(Event Loop)]
          │
      (主线程空闲?)
          │
   ┌──────┴────────┐
   │               │
   ▼               ▼
[主线程忙]      [主线程空]
(事件等待)       (事件出队,分发)
   │               │
   ▼               ▼
[主线程空闲后继续]
          │
          ▼
[事件分发到 btnA 回调]
          │
          ▼
[执行回调: console.log("点击A按钮")]
          │
          ▼
[事件循环下一轮]

例2:主线程被长时间阻塞

在这个例子中,先点击“按钮A”,然后连续点击“按钮B”五次。
最终控制台的输出如下:

点击A按钮
/*.........页面卡死中............*/
点击B按钮(5) // 打印了5次

代码例子如下:

<div>
  <button id="btnA">按钮A点击</button>
  <button id="btnB">按钮B点击</button>
</div>
<script>
  function blockMainThread() {
    let j = 5000000000;
    while (j--) {}
  }

  btnA.addEventListener("click", () => {
    console.log("点击A按钮");
    blockMainThread(); // 模拟主线程长时间阻塞
  });

  btnB.addEventListener("click", () => {
    console.log("点击B按钮");
  });
</script>

这个例子用来演示主线程被阻塞时,后续用户操作的处理方式。

流程解析:

  • 前半部分流程与例1一致:即先注册事件监听、用户点击、浏览器生成事件对象并进入事件队列等步骤。
  • 不同点:
    • 用户点击按钮A后,A的回调执行 blockMainThread(),主线程被阻塞。
    • 此时,用户多次点击按钮B,浏览器都记录下点击事件,这些事件只能进入队列,无法被立即处理。
    • 主线程恢复后,事件循环依次取出所有积压的点击事件,回调被快速批量执行,UI也会“补回”响应。

图解流程:

// 前半流程同例1
[注册监听器/用户点击/事件入队/主线程调度](详见例1)

【关键分歧】
   │
   ▼
[点击A → 执行A回调 → blockMainThread()]
   │
   └───► 主线程被阻塞,页面卡死,UI冻结
         │
         ▼
[主线程阻塞期间,点击B多次]
         │
         └───► B点击事件只被浏览器记录,事件队列积压,控制台无输出
         │
【主线程恢复】
         │
         └───► blockMainThread结束
                │
                ▼
[主线程空闲 → UI统一刷新(B按钮动画/反馈批量补上)]
                │
                ▼
[事件循环开始,依次处理所有B的点击事件]
                │
                └───► 控制台连续输出“点击B按钮”

例3:按钮B在回调中被隐藏

本例中,先点击“按钮A”,接着多次点击“按钮B”,
最终日志只会输出:

点击A按钮

代码例子:

<div>
  <button id="btnA">按钮A点击</button>
  <button id="btnB">按钮B点击</button>
</div>
<script>
  function blockMainThread() {
    let j = 5000000000;
    while (j--) {}
  }

  btnA.addEventListener("click", () => {
    console.log("点击A按钮");
    blockMainThread(); // 模拟主线程长时间阻塞
    btnB.style.display = "none";
  });

  btnB.addEventListener("click", () => {
    console.log("点击B按钮");
  });
</script>

这个例子用来说明,如果按钮B在A的回调末尾被隐藏,之前积压的B点击事件将不会再被处理。

流程解析:

  • 前半部分同例1。
  • 关键变化点:
    • A的回调最后执行 btnB.style.display = "none",在主线程结束整个回调后,B按钮才真正从页面上消失。
    • 所有在主线程阻塞期间产生的B点击事件,在A回调结束、主线程空闲后才被依次取出。
    • 但此时B已不可见,浏览器不会再派发点击事件给已隐藏的元素,因此B的监听器不会再执行。

图解流程:

// 前半流程同例1
[注册监听器/用户点击/事件入队/主线程调度](详见例1)

【关键分歧】
   │
   ▼
[点击A → 执行A回调 → blockMainThread()]
   │
   └───► 主线程阻塞,页面卡死
         │
         ▼
[主线程阻塞期间,点击B多次]
         │
         └───► B点击事件堆积队列,无法立即处理
         │
【blockMainThread结束后,A回调剩余代码执行】
         │
         └───► btnB.style.display = "none"  // B被设置为隐藏
         │
[主线程空闲,UI刷新]
         │
         ▼
[事件循环调度队列中的B点击事件]
         │
         └───► 但此时B已不可见,浏览器不会派发事件,B的回调不再执行

例4:浏览器优先处理用户交互事件

在这个例子中,先点击“按钮A”,等待1秒后再点击“按钮B”, 最终控制台只输出:

点击A按钮
点击B按钮

可以看到,即使 setTimeout 注册了回调,因为主线程被阻塞、并且B按钮的事件优先处理,定时器回调不会被执行。

<div>
  <button id="btnA">按钮A点击</button>
  <button id="btnB">按钮B点击</button>
</div>
<script>
  function blockMainThread() {
    let j = 5000000000;
    while (j--) {}
  }

  let timeout;
  btnA.addEventListener("click", () => {
    console.log("点击A按钮");
    timeout = setTimeout(() => {
      console.log("执行 setTimeout");
    }, 0);
    blockMainThread(); // 模拟主线程长时间阻塞
  });

  btnB.addEventListener("click", () => {
    console.log("点击B按钮");
    clearTimeout(timeout);
  });
</script>

流程解析:

  • 前半部分流程同例1。

  • 核心差异点:

    • 用户点击A后,A的回调内先通过 setTimeout 注册了一个定时器,接着调用 blockMainThread(),主线程随即被长时间阻塞。
    • 阻塞期间,setTimeout 的定时时间已到,它的回调被加入宏任务队列。随后,用户在1秒后点击了B,B的点击事件也进入了事件队列(但都无法被立即处理)。
    • 当主线程恢复后,浏览器会优先处理用户交互事件,即先执行B的点击事件回调,并在其中执行了 clearTimeout
    • 最后,setTimeout 的回调虽然还在宏任务队列里,但此时已经被 clearTimeout 清除,因此不会被执行。

图解流程:

// 前半流程同例1

【关键分歧】
   │
   ▼
[点击A → 执行A回调]
   ├─► setTimeout(() => {...}, 0)
   └─► blockMainThread()      // 主线程阻塞

[主线程阻塞期间,setTimeout定时已到,回调加入宏任务队列]
[约1秒后,用户点击B,B的点击事件也加入事件队列]

【主线程恢复】
   │
   ▼
[事件循环优先处理B的点击事件]
   │
   └───► 执行B回调(clearTimeout)
   │
[再轮到setTimeout回调时,已被clearTimeout,不会执行]

线上问题还原:防抖与事件队列覆盖

这是一个实际的线上场景,背景是问卷系统中的复杂逻辑与较高计算量。
例如,勾选某个问题后会决定后续题目的显示与否,且选项切换用到了防抖。下面用简化代码还原:

如果先点击“问题-1”(导致主线程阻塞),再依次点击“问题-2”、“问题-3”、“问题-4”以及“提交”按钮,
最终得到的数据对象如下,可以看到“question2”和“question3”的值都被覆盖了:

{
    "question1": "val",
    "question2": null,
    "question3": null,
    "question4": "val"
}
<div>
  <input type="checkbox" id="btnA" />
  <label for="btnA">问题-1(点击--主线程卡住)</label>
</div>
<div>
  <input type="checkbox" id="btnB" />
  <label for="btnB">问题-2</label>
</div>
<div>
  <input type="checkbox" id="btnC" />
  <label for="btnC">问题-3</label>
</div>
<div>
  <input type="checkbox" id="btnD" />
  <label for="btnD">问题-4</label>
</div>

<input id="submit" type="submit" value="提交" />

<script>
  function blockMainThread() {
    let j = 5000000000;
    while (j--) {}
  }

  const obj = {
    question1: null,
    question2: null,
    question3: null,
    question4: null,
  };

  btnA.addEventListener("click", () => {
    console.log("点击A按钮");
    obj.question1 = "val";
    blockMainThread(); // 模拟主线程长时间阻塞
  });

  let t;
  const debounce = (name) => {
    clearTimeout(t);
    t = setTimeout(() => {
      obj[name] = "val";
    }, 0);
  };

  btnB.addEventListener("click", () => {
    debounce("question2");
  });

  btnC.addEventListener("click", () => {
    debounce("question3");
  });

  btnD.addEventListener("click", () => {
    debounce("question4");
  });

  submit.addEventListener("click", () => {
    console.log(obj);
  });
</script>

流程解析:

  • 前半部分同例1。

  • 关键点:

    • 用户点击“问题-1”,主线程进入阻塞。
    • 在主线程阻塞期间,后续点击(问题2/3/4等)只会将事件加入队列,无法立即执行。
    • 由于防抖和事件队列堆积,只有主线程恢复后,最后一次的防抖回调被执行,中间的事件被覆盖。

图解流程

// 前半流程同例1
[注册监听器/用户点击/事件入队/主线程调度](详见例1)

【关键分歧】
   │
   ▼
[点击“问题-1”,主线程进入blockMainThread]
   │
   ▼
[主线程阻塞期间,点击“问题-2”“问题-3”“问题-4”“提交”]
   │
   └───► 事件全部积压在队列,且每次防抖回调会清除前一个,实际只保留最后一次
   │
【主线程恢复】
   │
   ▼
[事件循环依次处理队列]
   │
   └───► obj.question2/3 实际为 null,只剩最后一次(question4)生效

总结

通过这些具体例子的拆解和流程图解,可以看到JavaScript 事件循环的本质

  • 所有的事件处理、定时器、UI 更新都需要等主线程空闲,才能依次调度;
  • 如果主线程被卡死(如死循环或重计算),用户的操作、动画、甚至定时器都会被“冻结”在队列中,直到主线程恢复;
  • 浏览器会优先处理用户交互类的事件,但无论如何,都要等主线程空闲才可能执行;
  • 某些场景下(如元素已隐藏),即便事件在队列中,最终也可能不会被分发或处理;
  • 实际开发时,频繁的耗时操作、滥用同步任务、防抖/节流实现不当,都可能造成事件错失或用户体验异常。

理解事件循环和主线程调度机制,是编写高性能、不卡顿、交互流畅 Web 应用的基础。