彻底搞懂 JavaScript 事件循环

彻底搞懂 JavaScript 事件循环

一篇让你再也不会被"执行顺序"面试题绊倒的文章。


先解决一个根本问题:为什么需要事件循环?

JavaScript 是单线程语言。这意味着同一时间只能执行一个任务。

你可能会问:那异步操作怎么办?网络请求、定时器、用户点击------难道要全部阻塞主线程?

如果真是这样,点一个按钮页面就卡死了,浏览器直接弹出"页面未响应"。

事件循环(Event Loop) ,就是 JavaScript 在单线程限制下,实现"非阻塞异步"的核心机制。它的本质是:

通过任务队列,制造出"并发执行"的假象。


三个核心组件

组件 数据结构 存放内容 执行时机
调用栈 Call Stack LIFO(后进先出) 同步函数调用帧 立即执行
宏任务队列 Macrotask FIFO(先进先出) setTimeout、I/O、UI渲染等 栈清空后,每次取一个
微任务队列 Microtask FIFO(先进先出) Promise.then、MutationObserver 等 每次宏任务结束后,全部清空

记住一句话:微任务优先级高于宏任务,且每轮必须全部清空。


事件循环的完整执行流程

复制代码
① 执行一个宏任务(如整体 script 代码)
   ↓
② 执行过程中遇到异步操作:
   → 宏任务 → 放入宏任务队列
   → 微任务 → 放入微任务队列
   ↓
③ 当前宏任务执行完毕,调用栈清空
   ↓
④ ✅ 检查微任务队列 → 依次执行所有微任务(包括微任务产生的新微任务)
   ↓
⑤ 如有必要,执行 UI 渲染(浏览器环境)
   ↓
⑥ ❌ 取下一个宏任务执行
   ↓
⑦ 回到步骤③,循环往复

执行顺序口诀:同步代码 → 微任务清空 → 宏任务一个 → 循环。


一张表看清优先级

操作 队列 优先级 执行时机
console.log(同步) 调用栈 最高 立即执行
Promise.then / catch / finally 微任务 当前宏任务结束后立即执行
async/await 的后续代码 微任务 await 暂停处的后续代码入微任务队列
queueMicrotask 微任务 同上
MutationObserver 微任务 同上
setTimeout / setInterval 宏任务 下一轮事件循环
I/O 操作 宏任务 下一轮事件循环
UI 渲染(requestAnimationFrame) 宏任务 浏览器每帧渲染前
setImmediate(Node.js) 宏任务 Node 特殊阶段
process.nextTick(Node.js) 独立队列 超高 优先于微任务

用代码验证一切

案例 1:最经典的问题

javascript 复制代码
javascript
console.log('1. 同步开始');
setTimeout(() => {
  console.log('5. setTimeout');
}, 0);
Promise.resolve().then(() => {
  console.log('3. Promise');
});
console.log('2. 同步结束');

输出:1 → 2 → 3 → 5

解析:

  • 12 是同步代码,立即执行
  • setTimeout 回调入宏任务队列
  • Promise.then 回调入微任务队列
  • 同步代码执行完毕 → 先清空微任务(输出 3)→ 再取宏任务(输出 5

案例 2:微任务嵌套宏任务

javascript 复制代码
javascript
console.log('script start');
setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => console.log('promise in setTimeout'));
}, 0);
Promise.resolve().then(() => console.log('promise 1')).then(() => console.log('promise 2'));
console.log('script end');

输出:script start → script end → promise 1 → promise 2 → setTimeout 1 → promise in setTimeout

关键规则:在执行完一个宏任务后,会再次清空所有微任务。 所以 promise in setTimeout 不会等到下一轮宏任务,而是在当前宏任务(setTimeout 1)执行完后立即执行。


案例 3:async/await 的真相

javascript 复制代码
javascript
async function async1() {
  console.log('2. async1 start');
  await async2();
  console.log('6. async1 end');  // ← 这部分是微任务
}
async function async2() {
  console.log('3. async2');
}
console.log('1. script start');
setTimeout(() => console.log('8. setTimeout'), 0);
async1();
new Promise(resolve => {
  console.log('4. Promise executor');
  resolve();
}).then(() => console.log('7. Promise then'));
console.log('5. script end');

输出:1 → 2 → 3 → 4 → 5 → 6 → 7 → 8

await 的本质:async 函数中 await 之后的代码,等价于 Promise.then,被放入微任务队列。


浏览器 vs Node.js:有什么不同?

维度 浏览器 Node.js
实现依据 HTML 规范 libuv 库
阶段划分 相对简单 6 个阶段:timers → pending callbacks → poll → check → close callbacks → timers
特有 API requestAnimationFrame、MutationObserver process.nextTick、setImmediate
setImmediate vs setTimeout(fn,0) 无此 API I/O 未完成时 setImmediate 可能更快,否则顺序不确定

但核心规则一致:微任务永远在宏任务之前清空。


三条铁律(面试够用了)

规则 1:微任务优先

每执行完一个宏任务,必须清空所有微任务。微任务执行期间产生的新微任务,会在本次循环中继续执行。

规则 2:async/await = Promise.then

await 之后的代码是微任务,await 之前的代码是同步执行。

规则 3:宏任务多个来源,微任务只有一个队列

宏任务可能来自定时器、I/O、UI 渲染等不同来源,各自排队;微任务只有一个队列,按入队顺序执行。


写在最后

事件循环并不复杂,它就是一个不断检查调用栈是否为空、然后按优先级取任务执行的死循环

抓住两个关键词就够了:

  • 宏任务 vs 微任务
  • 调用栈清空后,先清微任务

剩下的,都是这两条规则的组合应用。

下次再遇到"这段代码输出什么"的问题,画一张调用栈 + 两个队列的图,答案自己就出来了。

相关推荐
橘猫走江湖1 小时前
Web 前端本地存储:localStorage 与 IndexedDB
前端·javascript·indexeddb
小强19881 小时前
CSS 布局进化史:从 Float 到 Flexbox 再到 Grid
前端
AKA__老方丈1 小时前
删除确认 Hook - 统一管理单删/批量删除的确认弹窗与执行
前端·javascript·vue.js
假如让我当三天老蒯1 小时前
React+TS 项目结构(自学项目用)
前端·react.js
yingyima1 小时前
Celery 分布式任务队列:我差点被这行代码坑死
前端
用户125758524361 小时前
XYGo Admin 即时通讯模块解析:基于 WebSocket 的企业级消息架构实践
前端
铁皮饭盒2 小时前
彩色命令行,Node21自带函数1行实现 ,Bun也兼容, 附Bun.color实现渐变色的代码
前端·后端
锋行天下2 小时前
关于websocket,真实场景踩坑经验
前端·后端
Asize2 小时前
重生之我在 Vibe Coding 时代当程序员:第十二课,Prompt 不是咒语,是可以沉淀的业务接口
前端·人工智能·python