【前端面试点】JavaScript的事件循环机制,包括宏任务、微任务的执行顺序

0 阅读6分钟

JavaScript 的事件循环(Event Loop)是其异步编程的核心机制。由于 JavaScript 是单线程的,它通过事件循环来协调各种任务的执行,使得非阻塞 I/O、定时器、用户交互等能够有序进行。理解事件循环的关键在于区分宏任务(MacroTask)微任务(MicroTask) ,并掌握它们的执行顺序。


1. 基本概念

  • 调用栈(Call Stack) :执行同步代码的地方,函数调用会以栈帧形式压入栈,执行完毕后弹出。

  • 任务队列(Task Queue) :存放待执行的异步任务回调。分为两类:

    • 宏任务队列(MacroTask Queue) :由宿主环境(浏览器、Node.js)提供的任务源产生的任务。
    • 微任务队列(MicroTask Queue) :由 JavaScript 自身提供的任务源产生的任务,优先级高于宏任务。

注意:规范中将宏任务简称为“task”,微任务称为“microtask”。HTML 标准规定每个事件循环有一个或多个任务队列(每个任务源对应一个队列),而微任务队列是唯一的。


2. 常见任务类型

任务类型常见来源
宏任务(Task)<script> 整体代码、setTimeoutsetIntervalsetImmediate(Node.js 独有)、I/O 操作、UI 渲染、MessageChannelpostMessage 等
微任务(Microtask)Promise.then/catch/finallyMutationObserverqueueMicrotaskprocess.nextTick(Node.js 环境,其优先级高于普通微任务)

3. 事件循环执行顺序

每一轮事件循环(一次 tick)的执行流程如下:

  1. 执行一个宏任务:从宏任务队列中取出最早的一个任务执行。首次执行时,<script> 整体代码就是第一个宏任务。
  2. 清空微任务队列:当前宏任务执行完毕后,立即检查微任务队列,并依次执行其中所有的微任务,直到队列为空。如果在执行微任务过程中产生了新的微任务,也会在本轮清空。
  3. 必要时更新渲染:浏览器可能会在此时进行 UI 渲染(渲染时机由浏览器决定,但通常发生在清空微任务之后、下一个宏任务之前)。
  4. 重复循环:从宏任务队列中取下一个任务,继续步骤 1。

关键原则

  • 微任务优先级高于宏任务:在当前宏任务结束后、下一个宏任务开始前,一定会清空微任务队列。
  • 微任务队列是逐轮清空:每一轮事件循环只会执行一个宏任务,但会清空所有微任务。
  • 渲染时机:渲染操作通常发生在微任务队列清空之后、下一个宏任务之前,但具体时机取决于浏览器的调度策略和帧率限制。

通俗理解

JavaScript 就像只有一个服务员,按以下规则干活:

  1. 先做完手头所有事(同步代码)。
  2. 立刻处理所有临时插进来的小事(微任务),直到小事清空。
  3. 再处理一件预约的大事(宏任务)。
  4. 大事办完后,回到步骤2(处理大事带来的新小事),然后继续步骤3(下一件大事),如此循环。

一句话总结:同步代码最先,然后所有微任务,接着一个宏任务,重复。


4. 示例解析

示例1:基础顺序

javascript

console.log('1'); // 同步代码

setTimeout(() => {
  console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 微任务
});

console.log('4'); // 同步代码

执行步骤

  • 当前宏任务为整体 <script>

    1. 输出 '1'
    2. 将 setTimeout 回调加入宏任务队列
    3. 将 Promise.then 回调加入微任务队列
    4. 输出 '4'
    5. 当前宏任务执行完毕
  • 检查微任务队列:执行 Promise.then 回调,输出 '3'

  • 微任务队列清空,可能渲染

  • 取出下一个宏任务(setTimeout 回调),输出 '2'

输出结果1 4 3 2

示例2:嵌套微任务

javascript

console.log('start');

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

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

console.log('end');

执行过程

  • 宏任务(整体代码):

    • 输出 'start'
    • 将第一个 Promise.then 加入微任务队列
    • 将 setTimeout 加入宏任务队列
    • 输出 'end'
  • 清空微任务:

    • 执行第一个 then:输出 'promise1',并将第二个 then 加入微任务队列
    • 继续清空微任务:执行第二个 then,输出 'promise2'
  • 微任务清空

  • 执行下一个宏任务:setTimeout 回调输出 'timeout'

输出start end promise1 promise2 timeout

示例3:混合宏任务与微任务

javascript

setTimeout(() => {
  console.log('timeout1');
  Promise.resolve().then(() => console.log('promise1'));
}, 0);

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

Promise.resolve().then(() => {
  console.log('promise2');
  setTimeout(() => console.log('timeout3'), 0);
});

执行过程

  • 宏任务(整体代码):

    • 将第一个 setTimeout 加入宏任务队列
    • 将第二个 setTimeout 加入宏任务队列
    • 将 Promise.then 加入微任务队列
  • 清空微任务:

    • 执行 promise2 回调:输出 'promise2',并将 setTimeout3 加入宏任务队列
  • 微任务清空

  • 宏任务1(第一个 setTimeout):

    • 输出 'timeout1'
    • 将 promise1 加入微任务队列
    • 宏任务结束,清空微任务:执行 promise1,输出 'promise1'
  • 宏任务2(第二个 setTimeout):

    • 输出 'timeout2'
  • 宏任务3(第三个 setTimeout):

    • 输出 'timeout3'

输出promise2 timeout1 promise1 timeout2 timeout3


5. 深入细节

  • 多个宏任务队列:根据规范,不同任务源(如 setTimeout、I/O、UI 渲染)可以拥有自己的队列,浏览器会按一定顺序(如优先处理高优先级队列)从中选取任务执行。但在实践中,可以近似看作一个先进先出的队列,但需注意 setTimeout 的延迟可能不精确。

  • 微任务队列唯一性:所有微任务共享一个队列,必须在本轮全部清空。

  • await 的处理async/await 是 Promise 的语法糖。await 后面的代码相当于 Promise.then 中的回调,属于微任务。

  • Node.js 环境的差异

    • Node.js 中的事件循环分为多个阶段(timers、pending callbacks、idle/prepare、poll、check、close callbacks),每个阶段对应一个宏任务队列。
    • process.nextTick 不属于微任务,但优先级高于微任务,会在当前阶段结束后、下一阶段前立即执行。
    • 浏览器环境没有 process.nextTick,但 queueMicrotask 可以添加微任务。

6. 为什么需要区分宏任务和微任务?

  • 宏任务 用于处理耗时较长的异步操作(如定时器、I/O),避免阻塞主线程。
  • 微任务 用于在当前宏任务结束后、渲染前执行高优先级的操作(如 Promise 回调、DOM 状态更新),保证在浏览器渲染之前完成必要的 JavaScript 计算,避免不必要的布局抖动和性能损失。

这种设计确保了异步任务的及时性和渲染的流畅性,是 JavaScript 高性能的基础。


7. 总结

  • 事件循环是一个永不停歇的循环,每次迭代处理一个宏任务,然后清空所有微任务。
  • 微任务始终在宏任务之后、下一个宏任务之前执行。
  • 理解事件循环能帮助开发者写出更可预测的异步代码,避免常见陷阱(如 setTimeout 嵌套的延迟、Promise 的执行时机等)。