一文搞懂JavaScript事件循环 (Event Loop)

55 阅读4分钟

你是否曾写过这样的代码,并对它的输出结果感到困惑?

console.log('脚本开始');

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

Promise.resolve().then(() => {
  console.log('Promise 回调');
});

console.log('脚本结束');

许多开发者会下意识地认为,setTimeout 的延迟是 0 毫秒,所以它会紧接着“脚本开始”之后立即执行。
然而,最终的控制台输出却是:

脚本开始
脚本结束
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)

先进先出的队列,存放宏任务:

  • setTimeout
  • setInterval
  • DOM 事件回调(clickscroll 等)
  • message channel

4. 微任务队列 (Microtask Queue)

先进先出的队列,存放微任务:

  • Promise.then / catch
  • MutationObserver
  • Node.js 的 process.nextTick

微任务优先级高于宏任务,一次事件循环会先清空所有微任务,再取一个宏任务执行。


DOM 事件与事件循环的关系

DOM 事件监听器是事件循环最常见的来源之一。来看一个简单例子:

<button id="myBtn">点我</button>
const myBtn = document.getElementById('myBtn');

console.log('同步代码:开始监听');

myBtn.addEventListener('click', () => {
  console.log('按钮被点击了!这是一个宏任务');
});

console.log('同步代码:监听设置完毕');

执行过程:

  1. addEventListener 同步执行,注册回调给浏览器 Web API。
  2. 浏览器后台监听点击事件,不阻塞 JavaScript 主线程。
  3. 用户点击按钮 → 浏览器将回调函数作为一个宏任务加入宏任务队列。
  4. 事件循环检测到队列有此任务,在清空微任务后执行它。

所以:DOM 事件的回调属于宏任务,与 setTimeout 同类。


事件循环的运转流程(图解)

graph TD
  A[执行调用栈中的同步任务] --> B[清空所有微任务队列]
  B --> C[取一个宏任务进入调用栈执行]
  C --> A

简化流程如下:

  1. 执行 同步任务(调用栈)。
  2. 清空 微任务队列(一次性全部)。
  3. 执行 一个 宏任务。
  4. 重复以上过程。

实战分析:开头示例的执行顺序

console.log('脚本开始');

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

Promise.resolve().then(() => {
  console.log('Promise 回调');
});

console.log('脚本结束');

执行过程:

  1. 输出:脚本开始
  2. 注册 setTimeout 回调(宏任务)
  3. 注册 Promise.then 回调(微任务)
  4. 输出:脚本结束
  5. 清空微任务队列 → 输出:Promise 回调
  6. 执行宏任务队列 → 输出:setTimeout 回调

更多任务类型分类表

来源类型优先级
setTimeout / setInterval宏任务
DOM 事件回调宏任务
Promise.then/catch微任务
MutationObserver微任务
Node.js process.nextTick微任务最高

复杂混合场景示例

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 之后的微任务');
})();

点击一次页面时可能的输出顺序:

Promise 微任务
async/await 之后的微任务
setTimeout 宏任务
DOM click 宏任务

总结与性能优化提示

  • 宏任务之间会执行所有微任务
  • 微任务可用于一些快速、紧急的异步逻辑(如数据校验、批量操作合并)。
  • 在需要延迟执行且不影响当前流程时,可用宏任务(如轻量的 UI 更新或延迟提示)。
  • 对性能优化的启示:
    • 合并多个 DOM 更新到一次宏任务中,减少回流/重绘。
    • 合理利用微任务处理短链异步,保持操作连贯性。

现在你已经掌握了 JavaScript 事件循环的基础与常见场景,不妨改写例子,加入更多事件和异步 API,亲自验证执行顺序。

你还有哪些和事件循环相关的经验?欢迎在评论区分享!