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

大家好,我是 前端架构师 - 大卫

更多优质内容请关注微信公众号 @程序员大卫

初心为助前端人🚀,进阶路上共星辰✨,

您的点赞👍与关注❤️,是我笔耕不辍的灯💡。

背景

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

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

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

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

一、注册事件监听器

页面渲染后,JS 代码为 btnA 注册 click 事件监听器:

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

这样浏览器就知道,btnA 被点击时需要通知 JS 执行回调。

二、用户点击按钮

用户在页面上实际点击了 btnA。

三、命中测试(Hit Testing)

浏览器根据鼠标坐标查找被点中的元素,考虑布局、z-index、透明度等规则。如果命中 btnA,则事件目标为 btnA。

四、生成事件对象

浏览器生成 MouseEvent 对象,记录点击类型、目标元素、鼠标坐标等信息,并将事件对象放入事件队列。

五、事件进入事件队列

事件对象进入队列,等待事件循环(Event Loop)调度。 主线程空闲时才会从队列中取出事件分发。

六、事件分发与回调执行

事件循环调度后,事件被分发给 btnA 的监听器,回调函数在主线程执行。

七、回调结束,主线程空闲

事件回调执行完毕,主线程恢复空闲,事件循环进入下一轮。

附:图解流程

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

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

在这个例子中,先点击"按钮A",然后连续点击"按钮B"五次。

最终控制台的输出如下:

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

代码例子如下:

html 复制代码
<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也会"补回"响应。

图解流程:

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

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

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

本例中,先点击"按钮A",接着多次点击"按钮B",

最终日志只会输出:

css 复制代码
点击A按钮

代码例子:

html 复制代码
<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的监听器不会再执行。

图解流程:

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

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

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

在这个例子中,依次点击"按钮A"和"按钮B",

最终控制台只输出:

css 复制代码
点击A按钮
点击B按钮

可以看到,即使setTimeout设置了回调,由于主线程阻塞、B按钮事件优先处理,定时器回调不会被执行。

html 复制代码
<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");
    }, 1_000);
    blockMainThread(); // 模拟主线程长时间阻塞
  });

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

流程解析:

  • 前半部分流程同例1:监听器注册、用户点击、事件入队、主线程调度。
  • 核心差异点:
    • 用户点击A后,A回调内 setTimeout 注册定时器,然后 blockMainThread() 阻塞主线程。
    • 阻塞期间,setTimeout 已到时间,回调进入宏任务队列,同时用户多次点击B也把事件入队。
    • 主线程恢复后,浏览器会优先处理用户交互事件(B的点击),每次执行B的回调并 clearTimeout,最后 setTimeout 的回调已被清除,不再执行。

图解流程:

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

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

[1秒后,setTimeout回调加入宏任务队列]
[主线程阻塞期间,点击B多次,B点击事件也入队]

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

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

这是一个实际的线上场景,背景是问卷系统中的复杂逻辑与较高计算量。

例如,勾选某个问题后会决定后续题目的显示与否,且选项切换用到了防抖。下面用简化代码还原:

如果先点击"问题-1"(导致主线程阻塞),再依次点击"问题-2"、"问题-3"、"问题-4"以及"提交"按钮,

最终得到的数据对象如下,可以看到"question2"和"question3"的值都被覆盖了:

json 复制代码
{
    "question1": "val",
    "question2": null,
    "question3": null,
    "question4": "val"
}
html 复制代码
<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等)只会将事件加入队列,无法立即执行。
    • 由于防抖和事件队列堆积,只有主线程恢复后,最后一次的防抖回调被执行,中间的事件被覆盖。

图解流程

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

【关键分歧】
   │
   ▼
[点击"问题-1",主线程进入blockMainThread]
   │
   ▼
[主线程阻塞期间,点击"问题-2""问题-3""问题-4""提交"]
   │
   └───► 事件全部积压在队列,且每次防抖回调会清除前一个,实际只保留最后一次
   │
【主线程恢复】
   │
   ▼
[事件循环依次处理队列]
   │
   └───► obj.question2/3 实际为 null,只剩最后一次(question4)生效
相关推荐
2301_781668612 分钟前
前端基础 JS Vue3 Ajax
前端
上单带刀不带妹25 分钟前
前端安全问题怎么解决
前端·安全
Fly-ping28 分钟前
【前端】JavaScript 的事件循环 (Event Loop)
开发语言·前端·javascript
SunTecTec1 小时前
IDEA 类上方注释 签名
服务器·前端·intellij-idea
在逃的吗喽1 小时前
黑马头条项目详解
前端·javascript·ajax
袁煦丞2 小时前
有Nextcloud家庭共享不求人:cpolar内网穿透实验室第471个成功挑战
前端·程序员·远程工作
小磊哥er2 小时前
【前端工程化】前端项目开发过程中如何做好通知管理?
前端
拾光拾趣录2 小时前
一次“秒开”变成“转菊花”的线上事故
前端
你我约定有三2 小时前
前端笔记:同源策略、跨域问题
前端·笔记
JHCan3332 小时前
一个没有手动加分号引发的bug
前端·javascript·bug