Event Loop(事件循环)是JavaScript实现异步编程的核心机制。它负责协调和管理JavaScript代码的执行、DOM更新、用户交互等各种事件。

image from Phillip Robert’s JSConfEU talk “What the heck is the event loop anyway?”
先理解为什么需要事件循环 🤔
想象你在餐厅点餐:
服务员(主线程)不能同时做多件事,但是餐厅需要同时处理:
- 接待新客人
- 点餐
- 上菜
- 结账
- 清理餐桌
这就像浏览器需要同时处理:
- 用户点击
- 处理网络请求
- 更新页面显示
- 执行 JavaScript 代码
JavaScript 就像一个独自工作的服务员,一次只能做一件事。但是通过事件循环的机制,可以让它看起来像是在同时处理多件事。

the Nodejs event loop
Event Loop 的执行顺序

图片来自JSConf.Asia-新加坡國會大廈劇院-2018年1月27日 https://2018.jsconf.asia
根据上图,Event Loop 的执行过程可以分为以下几个关键阶段:
- Task Queues (任务队列)
- Event Loop在循环中处理分配的任务
- requestAnimationFrame (rAF)
- 在每一帧开始时执行
- 用于处理动画相关的操作
- 保证动画流畅运行
- 渲染管线阶段(The Render steps)
-
Style (样式计算),计算元素的最终样式
-
Layout (布局),计算元素的位置和大小
-
Paint (绘制),将元素绘制到屏幕上
执行流程示例
console.log('script start'); // 宏任务
setTimeout(() => {
console.log('setTimeout'); // 下一个宏任务
}, 0);
requestAnimationFrame(() => {
console.log('rAF'); // 在下一帧执行
});
Promise.resolve().then(() => {
console.log('promise'); // 微任务
});
console.log('script end'); // 当前宏任务
输出顺序:
script start
script end
promise
setTimeout
rAF
代码执行分析
第一轮循环(当前宏任务)
- 执行同步代码
console.log('script start'); // 立即执行
console.log('script end'); // 立即执行
-
任务分配
-
setTimeout回调被放入宏任务队列 -
Promise.then回调被放入微任务队列 -
requestAnimationFrame回调被放入 rAF 队列 -
检查微任务队列
console.log('promise'); // 执行微任务
第二轮循环(下一个宏任务)
- 执行 setTimeout 回调
console.log('setTimeout');
最后执行 rAF
- 等待浏览器下一帧
console.log('rAF');
需要注意的是:
- setTimeout 的延迟
- 即使设置为 0,也有最小延迟时间,4.7ms为默认值
- 实际执行时间可能比预期要晚
- 执行优先级:
- 宏任务 > requestAnimationFrame > 渲染管线
- 微任务总是在当前宏任务结束后立即执行,优先级高于下一个宏任务
- 渲染时机:
- 不是每轮 Event Loop 都会触发渲染
- 浏览器会根据屏幕刷新率和性能来决定是否渲染
宏任务队列、RAF队列和微任务队列的对比
宏任务队列(Macrotask Queue)
执行时机
- 按照队列顺序依次执行
- 每次事件循环只执行一个
- 执行完后会检查微任务队列
// 多个宏任务按序执行
setTimeout(() => console.log(1), 0);
setTimeout(() => console.log(2), 0);
setTimeout(() => console.log(3), 0);
// 每个都是独立的宏任务
常见宏任务源
// 1. script(整体代码)
console.log('这是一个宏任务');
// 2. setTimeout
setTimeout(() => {
console.log('setTimeout 宏任务');
}, 0);
// 3. setInterval
setInterval(() => {
console.log('setInterval 宏任务');
}, 1000);
// 4. setImmediate (Node.js环境)
setImmediate(() => {
console.log('setImmediate 宏任务');
});
// 5. I/O操作
fs.readFile('file.txt', () => {
console.log('I/O 宏任务');
});
// 6. UI渲染
// 7. MessageChannel
// 8. postMessage
微任务队列(Microtask Queue)
执行时机
- 当前宏任务执行完立即执行
- 清空整个微任务队列,中途追加的微任务也会执行
- 优先于RAF和UI渲染
// 微任务可以添加新的微任务
Promise.resolve().then(() => {
console.log('Micro 1');
Promise.resolve().then(() => {
console.log('Micro 2'); // 同一轮循环执行
});
});
常见的微任务源
// 1. Promise的then/catch/finally回调
Promise.resolve().then(() => {
console.log('Promise.then 微任务');
});
// 2. async/await(实际上是Promise的语法糖)
async function example() {
await Promise.resolve();
console.log('async/await 微任务');
}
// 3. process.nextTick (Node.js环境)
process.nextTick(() => {
console.log('nextTick 微任务');
});
// 4. MutationObserver(监听DOM变化)
const observer = new MutationObserver(() => {
console.log('MutationObserver 微任务');
});
注意点
// process.nextTick 优先级高于 Promise.then
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 输出: nextTick -> promise
// 所有微任务都会在下一个宏任务之前执行完
Promise.resolve().then(() => {
console.log('1');
Promise.resolve().then(() => console.log('2'));
});
RAF队列(RequestAnimationFrame Callbacks)
执行特征
- 与显示器刷新率同步(通常是60fps)
- 在UI每帧渲染前执行,多余的任务会安排到下一帧
- 适合处理动画相关操作
// RAF与帧同步
requestAnimationFrame(() => {
console.log('Frame 1');
requestAnimationFrame(() => {
console.log('Frame 2'); // 下一帧执行
});
});
在事件监听器中
有以下代码
// 第一个点击监听器
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 1')); // 微任务
console.log('Listener 1'); // 同步代码
});
// 第二个点击监听器
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('Microtask 2')); // 微任务
console.log('Listener 2'); // 同步代码
});
当用户点击按钮时,输出顺序将是:
Listener 1
Microtask 1
Listener 2
Microtask 2
执行顺序解析
- 首先执行第一个监听器中的同步代码
console.log('Listener 1'),执行后堆栈为空,因为监听器已经执行完毕 - 此时执行微任务队列,即第一个Promise的回调
console.log('Microtask 1') - 然后执行第二个监听器中的同步代码
console.log('Listener 2') - 同理执行第二个Promise的回调
console.log('Microtask 2')
特殊情况
不是用户点击,而是通过JS触发时:
button.click(); // 同步调用
输出顺序将是:
Listener 1
Listener 2
Microtask 1
Microtask 2
button.click()是同步调用,在当前执行栈中直接执行,会立即执行所有注册的事件监听器。而用户手动点击是异步触发,会创建新的宏任务,所有同步代码(包括click()调用)执行完后,才会执行微任务队列。
执行顺序解析
- click调用,button同步分发事件,事件监听器按照注册顺序依次执行
- 首先执行第一个监听器中的同步代码
console.log('Listener 1'),执行完后,注意此时堆栈不为空,click还未执行完毕。 - 接着是第二个监听器中的同步代码
console.log('Listener 2') - 然后是微任务队列的清空,Microtask 1 和 Microtask 2