大家好,我是 前端架构师 - 大卫。
更多优质内容请关注微信公众号 @程序员大卫。
初心为助前端人🚀,进阶路上共星辰✨,
您的点赞👍与关注❤️,是我笔耕不辍的灯💡。
背景
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也会"补回"响应。
- 用户点击按钮A后,A的回调执行
图解流程:
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的监听器不会再执行。
- A的回调最后执行
图解流程:
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)生效