从一次线上问题聊聊 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)生效
相关推荐
子兮曰几秒前
🌏浏览器硬件API大全:30个颠覆性技术让你重新认识Web开发
前端·javascript·浏览器
即兴小索奇4 分钟前
Google AI Mode 颠覆传统搜索方式,它是有很大可能的
前端·后端·架构
大虾写代码12 分钟前
nvm和nrm的详细安装配置,从卸载nodejs到安装NVM管理nodejs版本,以及安装nrm管理npm版本
前端·npm·node.js·nvm·nrm
星哥说事13 分钟前
下一代开源 RAG 引擎,让你的 AI 检索与推理能力直接起飞
前端
....49213 分钟前
Vue3 与 AntV X6 节点传参、自动布局及边颜色控制教程
前端·javascript·vue.js
machinecat16 分钟前
Webpack模块联邦 - vue项目嵌套react项目部分功能实践
前端·webpack
Ares-Wang17 分钟前
Vue2 VS Vue3
javascript
今禾17 分钟前
深入浅出:ES6 Modules 与 CommonJS 的爱恨情仇
前端·javascript·面试
前端小白199517 分钟前
面试取经:Vue篇-Vue2响应式原理
前端·vue.js·面试
子兮曰17 分钟前
⭐告别any类型!TypeScript从零到精通的20个实战技巧,让你的代码质量提升300%
前端·javascript·typescript