详解 JavaScript 事件循环
事件循环(Event Loop)是 JavaScript 实现异步的核心机制。由于 JS 是单线程语言, 它通过事件循环在「调用栈」「Web API」「任务队列」之间协调工作, 让非阻塞的异步编程成为可能。
为什么需要事件循环?
JavaScript 引擎(如 V8)是单线程的, 同一时刻只能执行一段代码。如果所有操作都是同步的,一个网络请求就可能阻塞整个页面。
事件循环的出现,就是为了解决这个矛盾:让耗时操作(网络请求、定时器、用户交互)委托给浏览器的其他线程处理,主线程继续执行后续代码,等异步操作完成后再回调处理结果。
架构事件循环全景图
调用栈 Call Stack
记录函数执行上下文的栈结构,遵循 LIFO(后进先出) 原则。 每调用一个函数就压栈,执行完毕就弹栈。栈空时,事件循环开始检查任务队列。
Web APIs(宿主环境)
浏览器(或 Node.js)提供的多线程异步接口,包括:
宏任务队列 Macrotask
也叫 Task Queue,每轮事件循环只取 一个 宏任务执行。
微任务队列 Microtask
优先级高于宏任务,每轮事件循环会 清空所有 微任务。
⚡ 一轮事件循环的完整流程
详解事件循环的执行步骤
执行调用栈
同步代码直接推入 Call Stack,按后进先出(LIFO)顺序执行。
处理 Web API
遇到 setTimeout / fetch / DOM 事件等,交由浏览器 Web API 线程异步处理。
清空微任务队列
调用栈每清空一次,立即清空所有 Microtask(Promise.then / MutationObserver)。
取出一个宏任务
从 Macrotask 队列取出一个任务推入调用栈,然后回到步骤 1。
对比宏任务 vs 微任务
Macrotask(宏任务)
每次事件循环取一个
Microtask(微任务)
每轮清空全部微任务
关键优先级规则
微任务的执行优先级 远高于 宏任务。 事件循环在每次执行完一个宏任务(或同步代码)后,会优先清空整个微任务队列, 只有微任务队列为空时才会取出下一个宏任务。这意味着如果微任务不断产生新的微任务, 宏任务将被无限期推迟。
练习经典输出题详解
经典面试题 #1:基础宏微任务
基础代码
console.log('1 - 同步');
setTimeout(() => {
console.log('2 - 宏任务');
}, 0);
Promise.resolve().then(() => {
console.log('3 - 微任务');
});
console.log('4 - 同步');输出结果
解析
同步代码 1、4 先执行 → 调用栈清空 → 清空微任务队列(3)→ 取出宏任务(2)。
经典面试题 #2:微任务优先级
进阶代码
Promise.resolve()
.then(() => console.log('微任务1'))
.then(() => console.log('微任务2'));
Promise.resolve()
.then(() => console.log('微任务3'));
console.log('同步');输出结果
解析
两个 Promise 各自的 .then 链是独立注册的。微任务队列中:微任务1 → 微任务3 → 微任务2(因为微任务2 依赖微任务1 完成后才入队)。
经典面试题 #3:async/await 的本质
高级代码
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');输出结果
解析
await async2() 后面的代码相当于放入微任务队列。执行完同步代码后,先执行 async1 end(微任务1),再执行 promise2(微任务2)。
经典面试题 #4:宏任务嵌套微任务
进阶代码
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
setTimeout(() => {
console.log('4');
}, 0);
Promise.resolve().then(() => console.log('5'));
console.log('6');输出结果
解析
同步 1,6 → 微任务 5 → 第一个宏任务(2) → 产生的微任务(3) → 第二个宏任务(4)。注意:每执行完一个宏任务都要清空全部微任务。
深入async/await 与事件循环
await 的本质
async/await 只是 Promise 的语法糖。理解它的关键在于:
会暂停执行,将 await 后面的代码包装成微任务
将后续代码推入微任务队列,而非立即执行
类似于 yield,让事件循环继续处理其他任务
等价转换
await 写法
async function fn() {
const a = await fetch('/api');
const b = await a.json();
return b;
}Promise 写法
function fn() {
return fetch('/api')
.then(a => a.json())
.then(b => b);
}性能提示:如果两个 await 操作之间没有依赖关系, 应使用 Promise.all() 并行执行,避免不必要的串行等待。
拓展浏览器 vs Node.js 差异
浏览器环境
Node.js 环境
Node.js 事件循环六阶段
timers
setTimeout/Interval 回调
pending
系统级回调
idle/prepare
内部使用
poll
I/O 回调
check
setImmediate
close
关闭回调
避坑常见陷阱与最佳实践
setTimeout(fn, 0) ≠ 立即执行
setTimeout 的最小延迟约为 4ms(HTML5 规范)。即使设置 0ms,回调也会被放入宏任务队列,等同步代码和微任务全部执行完毕后才执行。
微任务可能造成页面卡顿
如果微任务中不断产生新的微任务(如递归 Promise.resolve().then()),宏任务和 UI 渲染将永远得不到执行机会,导致页面冻结。
合理使用 requestAnimationFrame
DOM 动画操作应放在 rAF 回调中,它在浏览器下一次渲染前执行,保证动画流畅。注意 rAF 是宏任务还是渲染步骤因浏览器实现而异。
总结:核心记忆要点
JS 是单线程的,事件循环是实现异步的核心机制
执行顺序:同步代码 → 微任务 → 宏任务(每次一个)
微任务优先级高于宏任务,每轮事件循环会清空全部微任务
await 后面的代码等价于 Promise.then 的回调(微任务)
每执行完一个宏任务,都要检查并清空微任务队列
Node.js 的事件循环比浏览器更复杂,有 6 个阶段