为什么需要事件循环?

JavaScript 最初是为浏览器设计的,核心特点是单线程。这意味着它一次只能干一件事,就像快餐店里只有一个点餐台和一个服务员。如果顾客点了一个需要 10 分钟现做的汉堡,服务员傻等着,后面的顾客就得排队干瞪眼,体验极差。

在 JavaScript 里,耗时操作(比如网络请求、定时器)就像这个“现做汉堡”。如果主线程一直等着这些操作完成,页面就会卡住,用户点击、滚动等操作都无法响应。事件循环就是解决这个问题的“调度大师”,它让 JavaScript 在单线程的基础上实现异步处理,保证流畅的用户体验。


快餐店模型:事件循环的四大核心组件

想象一家快餐店,事件循环的运行就像店里的工作流程,涉及以下四个核心部分:

  1. 调用栈(Call Stack):点餐台,服务员(JS 主线程)在这里处理同步任务。任务完成后立即离开,处理下一个。
  2. Web APIs(浏览器环境):后厨,负责处理耗时任务(比如 setTimeout、网络请求)。这些任务独立运行,不占用点餐台。
  3. 任务队列(Task Queue):取餐口,存放后厨处理完的异步任务回调。分为宏任务队列(普通取餐口)和微任务队列(VIP 取餐口)。
  4. 事件循环(Event Loop):调度员,时刻检查点餐台是否空闲。如果空了,先从 VIP 取餐口(微任务队列)取任务,再去普通取餐口(宏任务队列)。

这个“调度员”不断循环检查,形成事件循环,确保任务按序高效执行。


一次点餐体验:事件循环的完整流程

我们用一段代码来看看快餐店如何运转:

console.log('1. 开始点餐')
setTimeout(() => {
  console.log('3. 汉堡做好了,取餐!')
}, 2000)
console.log('2. 点了一杯可乐,瞬间拿到')

执行流程:

  1. 点餐台处理同步任务
    • console.log('1. 开始点餐') 进入点餐台,打印 1. 开始点餐,任务完成,出栈。
    • setTimeout 进入点餐台,这是个耗时任务,服务员把它交给后厨(Web APIs)计时,回调函数暂存后厨,点餐台清空。
    • console.log('2. 点了一杯可乐,瞬间拿到') 进入点餐台,打印 2. 点了一杯可乐,瞬间拿到,任务完成,出栈。
  2. 点餐台空了,调度员上场
    • 此时调用栈为空,事件循环开始检查。
    • 后厨计时 2 秒后,将 setTimeout 的回调放入普通取餐口(宏任务队列)。
  3. 调度员取餐
    • 调度员发现点餐台空了,普通取餐口有任务(回调函数)。
    • 将回调送到点餐台,执行后打印 3. 汉堡做好了,取餐!

输出顺序

1. 开始点餐
2. 点了一杯可乐,瞬间拿到
3. 汉堡做好了,取餐!

关键点setTimeout 的 2000 毫秒只是表示“2 秒后将回调放入队列”,实际执行时间还取决于点餐台是否空闲。


进阶:宏任务与微任务的 VIP 待遇

快餐店的取餐口其实有两种:**普通取餐口(宏任务队列)**和 VIP 取餐口(微任务队列)。VIP 客户(微任务)总是优先于普通客户(宏任务)。

  • 宏任务(Macrotask):如 setTimeoutsetInterval、DOM 事件回调、UI 渲染等,放在普通取餐口。
  • 微任务(Microtask):如 Promise.then/catch/finallyqueueMicrotaskMutationObserver,放在 VIP 取餐口,优先级更高。

事件循环的精细流程

  1. 执行点餐台的一个宏任务(比如整段脚本)。
  2. 宏任务执行完后,检查 VIP 取餐口,一次性清空所有微任务
  3. 清空后,再从普通取餐口取一个宏任务执行。
  4. 重复上述步骤。

代码示例

console.log('script start')

setTimeout(() => {
  console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
  console.log('promise1')
}).then(() => {
  console.log('promise2')
})

console.log('script end')

执行流程

  1. 整段脚本作为一个宏任务进入点餐台:
    • 打印 script start
    • setTimeout 回调交给后厨,放入普通取餐口。
    • Promise.resolve().then 回调放入 VIP 取餐口。
    • 打印 script end
  2. 点餐台清空,调度员检查 VIP 取餐口:
    • 执行第一个 .then,打印 promise1,它返回新 Promise,触发下一个 .then,加入 VIP 取餐口。
    • 执行第二个 .then,打印 promise2
  3. VIP 取餐口清空,检查普通取餐口:
    • 执行 setTimeout 回调,打印 setTimeout

输出顺序

script start
script end
promise1
promise2
setTimeout

规则总结一个宏任务 → 所有微任务 → 下一个宏任务


浏览器与 Node.js 的小差异

事件循环在浏览器和 Node.js 环境中有些不同:

  • 浏览器:每轮循环可能触发 UI 渲染(比如 requestAnimationFrame),微任务在渲染前执行。
  • Node.js:没有渲染阶段,但有 process.nextTick,优先级高于其他微任务,且宏任务队列按类型分优先级(如 setImmediate 优先于 setTimeout)。

示例(Node.js)

setTimeout(() => console.log('setTimeout'), 0)
process.nextTick(() => console.log('nextTick'))
Promise.resolve().then(() => console.log('promise'))

// 输出:nextTick → promise → setTimeout

原因process.nextTick 优先于 Promise,两者都先于 setTimeout


常见误区与实战建议

误区

  1. setTimeout(fn, 0)** 不是立刻执行**:它只是尽快将回调放入宏任务队列,需等调用栈和微任务队列清空。
  2. 微任务可能阻塞渲染:过多微任务(比如链式 Promise.then)会延迟 UI 更新,影响用户体验。
  3. 回调地狱:嵌套回调让代码难以维护,推荐使用 Promiseasync/await

实战建议

  1. 避免阻塞主线程:耗时操作(如大循环)尽量异步处理,比如用 setTimeout 分片执行。
  2. 合理使用微任务:微任务适合高优先级任务,但避免无限递归。
  3. 调试异步代码:用 console.log 或调试工具(如 Chrome DevTools)跟踪任务执行顺序。
  4. 优化性能:监控任务队列堆积,避免微任务过多导致渲染延迟。

总结:事件循环的精髓

事件循环是 JavaScript 异步编程的基石,通过调用栈任务队列事件循环协调单线程运行。它的核心规则是:

  1. 同步代码优先执行,放入调用栈。
  2. 异步任务交给 Web APIs,完成后回调进入宏任务或微任务队列。
  3. 每轮循环:执行一个宏任务 → 清空所有微任务 → UI 渲染(浏览器)→ 下一轮。

掌握事件循环,不仅能帮你理解代码执行顺序,还能优化性能、避免异步陷阱。无论是调试复杂前端逻辑,还是应对面试中的异步题目,它都是你的利器。