浏览器事件循环与异步编程
浏览器事件循环与异步编程
事件循环(Event Loop)是 JavaScript 异步编程的核心机制,是"单线程的 JavaScript 为什么能同时处理动画、网络请求、用户点击"这个问题的标准答案。无论是面试高频题,还是排查页面卡顿、接口回调顺序诡异的线上问题,最终都会落到对事件循环的理解上。本文从一个生活类比出发,逐层拆解执行栈、任务队列、微任务/宏任务、渲染时机,再延伸到 Web Worker、长任务优化、Node.js 事件循环差异,并配有大量可运行代码示例。
一、用一个类比理解事件循环
餐厅点餐的类比:
想象你在一家繁忙的餐厅里:
这里,"服务员只有一个"对应 JavaScript 的单线程模型,"厨房做菜"对应异步操作(定时器、网络请求),"取餐台排队 + 服务员轮询"就是事件循环:不断检查主线程是否空闲,空闲就从队列里取出任务执行。
定义:
为什么必须理解它:
小结: 事件循环 = 一个不知疲倦的调度员,反复问自己"执行栈空了吗?微任务队列还有事吗?该渲染画面了吗?宏任务队列里下一个是谁?",靠这套固定的检查顺序,让单线程的 JS 实现了"看起来并发"的效果。
二、JavaScript 的单线程模型
概念:
JavaScript 引擎(如 V8)在主执行环境中只有一条主线程用来执行 JS 代码。这与 Java、C++ 等语言可以轻松开多线程并发执行不同。
为什么 JavaScript 被设计成单线程:
单线程带来的问题与解决方案:
单线程 vs 多线程对比:
| 维度 | JavaScript 单线程 + 事件循环 | 传统多线程模型 |
| --- | --- | --- |
| 并发方式 | 通过任务队列和事件循环模拟并发 | 真正的多线程并行执行 |
| 数据安全 | 天然无竞态条件,无需加锁 | 需要锁、信号量等同步机制 |
| 编程复杂度 | 相对简单,但需理解异步顺序 | 复杂,容易死锁、竞态 |
| CPU 密集型任务 | 会阻塞主线程,需要 Web Worker | 可以用独立线程并行计算 |
| 适用场景 | UI 交互、I/O 密集型 | 计算密集型、并行计算 |
代码示例:单线程阻塞演示
console.log('任务开始');
// 模拟一个耗时的同步计算(阻塞主线程)
function blockingTask(ms) {
const start = Date.now();
while (Date.now() - start < ms) {
// 空转,占用 CPU,期间页面完全无法响应点击、无法渲染
}
}
console.log('开始执行耗时任务...');
blockingTask(3000); // 主线程会被卡住 3 秒
console.log('耗时任务结束');
// 在这 3 秒内:
// - 页面上的按钮点击不会有任何反应
// - CSS 动画会卡住
// - 即使有 setTimeout(fn, 0) 排队,也要等这 3 秒过完才能执行常见坑:
最佳实践:
三、执行栈(Call Stack)
概念:
执行栈(也叫调用栈)是 JS 引擎用来跟踪函数调用的一种数据结构,遵循后进先出(LIFO)的规则。
工作原理:
代码示例:观察执行栈的入栈出栈
function third() {
console.log('third 执行,此时栈: main -> first -> second -> third');
console.trace(); // 打印当前调用栈
}
function second() {
console.log('second 执行,此时栈: main -> first -> second');
third();
console.log('third 执行完毕,second 继续执行');
}
function first() {
console.log('first 执行,此时栈: main -> first');
second();
console.log('second 执行完毕,first 继续执行');
}
first();
console.log('first 执行完毕,栈恢复为空');
// 输出顺序:
// first 执行,此时栈: main -> first
// second 执行,此时栈: main -> first -> second
// third 执行,此时栈: main -> first -> second -> third
// third 执行完毕,second 继续执行
// second 执行完毕,first 继续执行
// first 执行完毕,栈恢复为空代码示例:递归过深导致栈溢出
function recursiveCall(n) {
// 没有终止条件,或终止条件设置过大
return recursiveCall(n + 1);
}
try {
recursiveCall(0);
} catch (e) {
console.log(e.message); // Maximum call stack size exceeded
}调试技巧:
小结: 执行栈只关心"正在跑的同步代码",它必须完全清空,事件循环才会去处理任务队列——这也是为什么一个死循环会让所有的 setTimeout、Promise 都"卡住不执行"的原因:它们不是没被调度,而是排在了一个永远不会清空的执行栈后面。
四、宏任务与微任务
概念:
异步回调不会立刻执行,而是被放进不同的"队列"里排队等待执行栈清空后被取出执行。这些队列按照优先级分为两大类:宏任务(Macrotask / Task) 和 微任务(Microtask / Job)。
宏任务(Macrotask)包括: