大家好,我是前端架构师,关注微信公众号【程序员大卫】:
- 回复 [面试] :免费领取“前端面试大全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也会“补回”响应。
- 用户点击按钮A后,A的回调执行
图解流程:
// 前半流程同例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的监听器不会再执行。
- A的回调最后执行
图解流程:
// 前半流程同例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清除,因此不会被执行。
- 用户点击A后,A的回调内先通过
图解流程:
// 前半流程同例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 应用的基础。