JavaScript 事件循环:从宏任务到微任务

113 阅读6分钟

JavaScript 是一门单线程语言,这意味着同一时间只能执行一个任务。但为何在我们的页面中依然能实现异步操作、定时器、网络请求等并发效果?答案就在于 事件循环(Event Loop)


目录

  1. JavaScript 的单线程与并发模型
  2. 什么是事件循环
  3. 宏任务和微任务
  4. 事件循环的执行顺序
  5. 代码示例解析
  6. 常见问题及注意事项
  7. 最新说法
  8. 总结

1. JavaScript 的单线程与并发模型

JavaScript 在浏览器和 Node.js 中都是单线程执行的,这意味着所有代码都运行在一个线程上,只有一个调用栈(Call Stack)。传统上,这似乎会导致阻塞问题,但 JavaScript 的并发模型结合了异步 I/O事件循环以及任务队列,使得它能够高效地处理并发任务。
简单来说,异步任务不会阻塞主线程,而是在任务完成后将回调函数放入队列中,等待事件循环执行。


2. 什么是事件循环

事件循环是 JavaScript 的一项机制,用于不断检查调用栈是否为空,并从任务队列中取出任务执行。
事件循环的核心思想是:

  • 当调用栈为空时,将任务队列中的任务(宏任务或微任务)依次推入调用栈中执行。
  • 它确保了异步任务不会干扰当前正在执行的同步代码,同时在任务完成后能够及时响应。

浏览器或 Node.js 的运行时会不断循环检查并执行任务,这就是“事件循环”。

3. 宏任务和微任务

JavaScript 任务可以分为两大类:宏任务(Macro Task)微任务(Micro Task)

宏任务

  • 定义:宏任务包含整个独立任务,比如整体的脚本代码、setTimeoutsetInterval、I/O 操作等。
  • 执行时机:每一次事件循环从任务队列中取出一个宏任务执行,执行完成后再执行所有微任务。

微任务

  • 定义:微任务是相对于宏任务来说更为轻量的小任务,如 Promise.then()Promise.catch()process.nextTick(Node.js 中)等。
  • 执行时机:每次宏任务执行完毕后,事件循环会立即执行所有微任务,直到微任务队列为空后,再开始下一个宏任务。

两者关系

  • 微任务的优先级高于宏任务。也就是说,在当前宏任务执行完毕后,会把所有微任务执行完再继续下一个宏任务。
  • 这就解释了为什么在某些场景下,Promise.then() 的回调会先于 setTimeout 的回调执行。

4. 事件循环的执行顺序

一个典型的事件循环过程可以总结如下:

  1. 执行全局同步代码:首先执行所有同步任务,填充调用栈。
  2. 执行宏任务:调用栈为空后,从宏任务队列中取出第一个任务执行。
  3. 执行微任务:当前宏任务执行完毕后,事件循环会检查微任务队列,将所有微任务依次执行,直到微任务队列为空。
  4. 渲染页面(浏览器) :执行完所有微任务后,浏览器可能执行页面渲染(如果需要)。
  5. 继续下一个循环:返回步骤2,执行下一个宏任务。

5. 代码示例解析

下面通过一个代码示例来说明事件循环的执行顺序:

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() 创建一个已解决的 Promise,其 then 回调被加入微任务队列。
    • 打印 script end
  2. 调用栈为空

    • 当前宏任务(全局代码)结束后,事件循环检查微任务队列,开始执行所有微任务:

      • 首先执行第一个 then 回调,打印 promise1
      • 接着,第一个 then 的返回值生成新的微任务(第二个 then),执行并打印 promise2
  3. 执行宏任务队列

    • 此时微任务队列为空,事件循环开始执行宏任务队列中的任务,即 setTimeout 的回调,打印 setTimeout

最终输出顺序:

script start
script end
promise1
promise2
setTimeout

6. 常见问题及注意事项

6.1 微任务队列可能无限执行吗?

虽然微任务队列的优先级很高,但开发者应注意不要在微任务中不断添加新的微任务,否则可能导致页面长时间卡顿甚至死循环。

6.2 与浏览器渲染的关系

在浏览器环境中,每次事件循环后,浏览器会执行页面渲染。如果微任务队列中有大量任务,会延迟页面的渲染,影响用户体验。因此,尽量将耗时的操作分散到多个事件循环中进行。


7. 最新说法

随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法。根据 W3C 的最新解释:

  • 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。 在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
  • 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行

在目前 chrome 的实现中,至少包含了下面的队列:

  • 延时队列:用于存放计时器到达后的回调任务,优先级「中」
  • 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」
  • 微队列:用户存放需要最快执行的任务,优先级「最高」

8. 总结

  • 事件循环 是 JavaScript 处理异步任务的核心机制。
  • 任务分为 宏任务微任务,微任务总是在当前宏任务结束后、下一个宏任务开始前执行。
  • 理解事件循环有助于编写出更高效、更健壮的异步代码,并帮助你调试一些看似神秘的执行顺序问题。