你是否曾写过这样的代码,并对它的输出结果感到困惑?
javascript
console.log('脚本开始');
setTimeout(() => {
console.log('setTimeout 回调');
}, 0);
Promise.resolve().then(() => {
console.log('Promise 回调');
});
console.log('脚本结束');
许多开发者会下意识地认为,setTimeout 的延迟是 0 毫秒,所以它会紧接着"脚本开始"之后立即执行。
然而,最终的控制台输出却是:
javascript
脚本开始
脚本结束
Promise 回调
setTimeout 回调
为什么会这样?为什么 Promise 的回调插在了 setTimeout 前面?
这个看似简单的现象背后,隐藏着 JavaScript 异步编程的核心------事件循环(Event Loop)。
为什么我们需要事件循环?
JavaScript 是一门 单线程 语言,这意味着在任何给定时刻,它只能执行一件任务。
优点是避免了多线程竞争、锁等问题;缺点是,一旦执行耗时任务,就会阻塞所有其他操作,造成页面卡顿。
为了既保持单线程的简单性,又能处理耗时任务,JavaScript 依赖宿主环境(浏览器 / Node.js)来协作,通过事件循环调度任务的执行。
事件循环的关键组成部分
1. 调用栈 (Call Stack)
后进先出(LIFO)的数据结构,存放所有正在执行的函数调用。
2. Web APIs / Node.js APIs
宿主环境提供的能力,例如:
setTimeout/setInterval- DOM 事件监听(
addEventListener) - AJAX / Fetch 网络请求
- Node.js 的文件 I/O 等
3. 宏任务队列 (Macrotask Queue)
先进先出的队列,存放宏任务:
setTimeoutsetInterval- DOM 事件回调(
click、scroll等) - message channel
4. 微任务队列 (Microtask Queue)
先进先出的队列,存放微任务:
Promise.then / catchMutationObserver- Node.js 的
process.nextTick
微任务优先级高于宏任务,一次事件循环会先清空所有微任务,再取一个宏任务执行。
DOM 事件与事件循环的关系
DOM 事件监听器是事件循环最常见的来源之一。来看一个简单例子:
html
<button id="myBtn">点我</button>
javascript
const myBtn = document.getElementById('myBtn');
console.log('同步代码:开始监听');
myBtn.addEventListener('click', () => {
console.log('按钮被点击了!这是一个宏任务');
});
console.log('同步代码:监听设置完毕');
执行过程:
addEventListener同步执行,注册回调给浏览器 Web API。- 浏览器后台监听点击事件,不阻塞 JavaScript 主线程。
- 用户点击按钮 → 浏览器将回调函数作为一个宏任务加入宏任务队列。
- 事件循环检测到队列有此任务,在清空微任务后执行它。
所以:DOM 事件的回调属于宏任务 ,与 setTimeout 同类。
事件循环的运转流程(图解)
graph TD
A[执行调用栈中的同步任务] --> B[清空所有微任务队列]
B --> C[取一个宏任务进入调用栈执行]
C --> A
简化流程如下:
- 执行 同步任务(调用栈)。
- 清空 微任务队列(一次性全部)。
- 执行 一个 宏任务。
- 重复以上过程。
实战分析:开头示例的执行顺序
javascript
console.log('脚本开始');
setTimeout(() => {
console.log('setTimeout 回调');
}, 0);
Promise.resolve().then(() => {
console.log('Promise 回调');
});
console.log('脚本结束');
执行过程:
- 输出:
脚本开始 - 注册
setTimeout回调(宏任务) - 注册
Promise.then回调(微任务) - 输出:
脚本结束 - 清空微任务队列 → 输出:
Promise 回调 - 执行宏任务队列 → 输出:
setTimeout 回调
更多任务类型分类表
| 来源 | 类型 | 优先级 |
|---|---|---|
setTimeout / setInterval |
宏任务 | 低 |
| DOM 事件回调 | 宏任务 | 低 |
Promise.then/catch |
微任务 | 高 |
MutationObserver |
微任务 | 高 |
Node.js process.nextTick |
微任务 | 最高 |
复杂混合场景示例
javascript
document.body.addEventListener('click', () => {
console.log('DOM click 宏任务');
});
setTimeout(() => {
console.log('setTimeout 宏任务');
}, 0);
Promise.resolve().then(() => {
console.log('Promise 微任务');
});
(async function(){
await Promise.resolve();
console.log('async/await 之后的微任务');
})();
点击一次页面时可能的输出顺序:
javascript
Promise 微任务
async/await 之后的微任务
setTimeout 宏任务
DOM click 宏任务
总结与性能优化提示
- 宏任务 之间会执行所有微任务。
- 微任务可用于一些快速、紧急的异步逻辑(如数据校验、批量操作合并)。
- 在需要延迟执行且不影响当前流程时,可用宏任务(如轻量的 UI 更新或延迟提示)。
- 对性能优化的启示:
- 合并多个 DOM 更新到一次宏任务中,减少回流/重绘。
- 合理利用微任务处理短链异步,保持操作连贯性。
现在你已经掌握了 JavaScript 事件循环的基础与常见场景,不妨改写例子,加入更多事件和异步 API,亲自验证执行顺序。
你还有哪些和事件循环相关的经验?欢迎在评论区分享!