一、什么是事件循环?
JavaScript 作为一门单线程语言,却能高效处理异步操作,其核心机制就是事件循环(Event Loop) 。这种设计使得 JavaScript 在执行代码、处理事件和管理异步操作时既高效又有序。
为什么需要事件循环?
- 单线程限制:JavaScript 只有一个主线程,不能像多线程语言那样并发执行任务
- 非阻塞需求:需要处理网络请求、用户交互等异步操作而不冻结界面
- 执行顺序管理:需要合理调度同步任务、微任务和宏任务的执行顺序
二、事件循环的核心组件
1. 调用栈(Call Stack)
- 后进先出(LIFO)的数据结构
- 存储函数调用信息(执行上下文)
- 当函数执行时入栈,执行完毕出栈
function foo() {
console.log('foo');
bar();
}
function bar() {
console.log('bar');
}
foo();
执行过程:
foo()
入栈 →console.log('foo')
入栈 → 打印 → 出栈bar()
入栈 →console.log('bar')
入栈 → 打印 → 出栈bar()
出栈 →foo()
出栈
2. 任务队列(Task Queue)
-
宏任务队列(Macrotask Queue)
- setTimeout/setInterval
- I/O 操作
- UI 渲染
- 事件回调
-
微任务队列(Microtask Queue)
- Promise.then/catch/finally
- MutationObserver
- queueMicrotask
- process.nextTick(Node.js)
3. Web APIs 环境
- 浏览器提供的异步 API
- 包括:DOM API、定时器、网络请求等
- 实际执行在浏览器其他线程中
三、完整事件循环流程
阶段 1:同步代码执行
console.log('脚本开始');
setTimeout(() => {
console.log('setTimeout回调');
}, 0);
Promise.resolve().then(() => {
console.log('Promise微任务');
});
console.log('脚本结束');
执行过程:
- 执行所有同步代码(输出"脚本开始"和"脚本结束")
- 遇到
setTimeout
交给 Web APIs 计时器线程 - 遇到
Promise
将回调放入微任务队列
阶段 2:微任务执行
- 检查微任务队列
- 执行所有微任务(包括微任务中产生的新微任务)
- 直到微任务队列完全清空
上例中:
- 执行 Promise 回调(输出"Promise微任务")
阶段 3:渲染阶段(浏览器)
- 执行 UI 渲染(如果需要)
- 执行
requestAnimationFrame
回调
阶段 4:宏任务执行
- 从宏任务队列取出一个任务执行
- 执行该任务产生的所有同步代码
上例中:
- 执行 setTimeout 回调(输出"setTimeout回调")
阶段 5:循环回到微任务
- 检查执行该宏任务产生的微任务
- 重复上述过程
四、复杂场景分析
案例 1:嵌套任务
console.log('开始');
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);
setTimeout(() => {
console.log('timeout2');
}, 0);
Promise.resolve().then(() => {
console.log('promise2');
setTimeout(() => console.log('timeout3'), 0);
});
console.log('结束');
输出顺序:
- 开始
- 结束
- promise2
- timeout1
- promise1
- timeout2
- timeout3
执行解析:
- 同步代码执行,注册两个 setTimeout 和一个 Promise
- 执行微任务(输出 promise2),同时注册 timeout3
- 执行第一个宏任务 timeout1,输出后产生新的微任务 promise1
- 执行新微任务 promise1
- 执行下一个宏任务 timeout2
- 最后执行在微任务中注册的 timeout3
案例 2:微任务递归
function recursiveMicrotask(count = 0) {
if(count >= 3) return;
queueMicrotask(() => {
console.log(`微任务 ${count}`);
recursiveMicrotask(count + 1);
});
}
console.log('开始');
recursiveMicrotask();
console.log('结束');
输出:
- 开始
- 结束
- 微任务 0
- 微任务 1
- 微任务 2
关键点:
- 微任务会一直执行直到队列清空
- 递归添加的微任务会阻塞主线程
- 可能导致页面无响应(慎用)
五、Node.js 与浏览器差异
浏览器环境:
宏任务 → 所有微任务 → 渲染 → 下一个宏任务
Node.js 环境:
┌───────────────────────┐
┌>│ timers │ (setTimeout/setInterval)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ pending callbacks │ (I/O回调)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │ (内部使用)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└─| poll │ (检索新I/O事件)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ check │ (setImmediate)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└ ┤ close callbacks │ (关闭事件回调)
└───────────────────────┘
关键区别:
process.nextTick
优先级高于微任务setImmediate
在 check 阶段执行- 多个阶段组成循环,而非简单队列
六、最佳实践与常见问题
1. 合理分解长任务
// 不良实践
function longTask() {
// 执行耗时操作(>50ms)
}
// 良好实践
async function chunkedTask() {
// 第一部分
await new Promise(resolve => setTimeout(resolve));
// 第二部分
}
2. 避免微任务无限循环
// 危险代码!
function infiniteMicrotask() {
Promise.resolve().then(infiniteMicrotask);
}
3. 正确使用 setTimeout(0)
// 需要DOM更新后操作
element.style.transform = 'translateX(100px)';
setTimeout(() => {
// 确保样式已应用
console.log(element.getBoundingClientRect());
}, 0);
4. 优先使用微任务
// 比setTimeout更高效
function urgentTask() {
queueMicrotask(() => {
// 紧急但不阻塞的任务
});
}
七、总结图表
事件循环流程图:
┌───────────────────────┐
│ 同步代码执行 │
└──────────┬────────────┘
│
┌──────────▼────────────┐
│ 执行所有微任务 │(直到队列清空)
└──────────┬────────────┘
│┌──────────▼────────────┐
│ 浏览器:渲染 │
└──────────┬────────────┘
│
┌──────────▼────────────┐
│ 执行一个宏任务 │
└──────────┬────────────┘
│
┌──────────▼────────────┐
│ 重复循环... │
└───────────────────────┘
执行优先级:
同步代码 > process.nextTick > Promise微任务 > 宏任务
理解事件循环机制是成为高级 JavaScript 开发者的关键一步。通过合理利用微任务和宏任务的特性,可以编写出更高效、响应更快的应用程序。记住:同步代码总是最先执行,微任务永远优先于宏任务,这是掌握事件循环的核心原则。