JavaScript 事件循环详解:宏任务与微任务的执行顺序

周予安
周予安
发布时间:2026-10-15 | 3人浏览过
原创

JavaScript 作为单线程语言,通过事件循环(Event Loop)机制协调同步与异步操作。本文将拆解宏任务与微任务的执行差异,剖析从代码加载到 DOM 渲染全过程的任务调度逻辑,帮助开发者掌握异步编程的核心原理。

前言:单线程与异步的基础

JavaScript 引擎在浏览器和 Node.js 中均运行于单线程环境。这意味着在同一时刻,解释器只能执行一段代码。若所有操作均为同步阻塞式(如复杂的计算或长网络请求),页面将陷入无响应状态,甚至导致白屏。为了解决这一矛盾,JavaScript 采用了异步非阻塞模型:将耗时操作放入独立的队列,主线程优先处理同步代码,待空闲时再按序取出异步任务执行。

任务分类:宏任务与微任务

异步任务并非无序堆积,而是根据执行优先级被划分为两类:

第1步:识别宏任务(Macrotask)

宏任务通常由浏览器或宿主环境发起,其核心特征是在当前脚本执行完毕后的下一轮事件循环(即 DOM 渲染后)执行。常见的宏任务包括:

  • setTimeout / setInterval:定时器任务。即使延迟设为 0,也必须等待当前调用栈清空且时间满足条件后才进入队列。
  • XMLHttpRequest:网络请求完成后的回调。
  • UI rendering:浏览器的 UI 渲染更新本身也可视为一种宏任务。

第2步:识别微任务(Microtask)

微任务具有更高的执行优先级,它们将在当前宏任务执行完毕后、下一次 DOM 渲染之前立即执行。常见的微任务包括:

  • Promise.then / .catch:Promise 状态改变后的回调。
  • queueMicrotask():显式创建的微任务。
  • MutationObserver:DOM 变更监听回调。
console.log('start');
// 添加微任务
Promise.resolve().then(() => {
    console.log('promise then');
});
queueMicrotask(() => {
    console.log('queueMicrotask');
});
console.log('end');

执行结果验证了微任务的优先性:start 和 end 先输出,随后按顺序输出 promise then 和 queueMicrotask。

核心机制:事件循环(Event Loop)流程

理解事件循环需关注三个关键组件的协作:调用栈(Call Stack)、任务队列(Task Queue,分宏/微)以及 HTML 渲染管线。

完整的执行顺序如下:

  1. 执行同步代码:遇到函数压入调用栈,遇到异步函数将其分配至对应的宏任务或微任务队列。
  2. 清理微任务队列:当前调用栈清空后,引擎会检查微任务队列。如果存在微任务,全部依次执行(注意:在执行新的微任务期间产生的新微任务,也会在本轮被追加并执行)。直到微任务队列为空。
  3. 界面渲染:如果有重排(Reflow)或重绘(Repaint)的需求,浏览器在此阶段更新屏幕。
  4. 执行宏任务:从宏任务队列头部取出一个任务,推入调用栈执行。
  5. 循环往复:重复步骤 2-4,直至所有队列清空。

执行规则总结

  • 微任务先于宏任务:只要微任务队列不为空,就不会切换到下一个宏任务。
  • 渲染时机:微任务在渲染前执行,可影响即将呈现的 DOM;宏任务在渲染后执行。
  • FIFO 原则:同类任务遵循先进先出,但微任务可能因本轮执行产生新的微任务而延长执行时间。

复杂场景演练

第1步:微任务嵌套宏任务

当在微任务中创建宏任务时,新宏任务会被加入宏任务队列,等待下一轮循环。

new Promise((resolve) => {
  console.log('promise 1');
  // 在 Promise 内部添加宏任务
  setTimeout(() => { console.log('setTimeout 1'); }, 500);
  resolve();
}).then(() => {
  console.log('promise then');
  // 在 then 中添加另一个宏任务
  setTimeout(() => { console.log('setTimeout 2'); }, 0);
});
new Promise((resolve) => {
  console.log('promise 2');
  resolve();
});

结果分析:

  1. 首先执行同步部分的两个 Promise 构造器,输出 promise 1、promise 2。
  2. 微任务队列中有两个 then 回调(第二个 Promise 虽未链式调用 then,但在某些环境中可能有默认行为,此处主要看第一个已定义的 then)。
  3. 执行 promise then,在此过程中创建了 setTimeout 2(0ms)。
  4. 微任务清空,进入渲染阶段。
  5. 执行宏任务:setTimeout 2 和 setTimeout 1 均在队列中。由于 setTimeout 2 延迟极短且触发早,通常优先被执行(具体取决于环境调度,但逻辑上属于同一轮次的后续宏任务)。

第2步:宏任务嵌套微任务

这是最容易混淆的场景。关键在于:只有当前宏任务彻底结束且微任务队列为空时,才会去取下一个宏任务。

setTimeout(() => {
  console.log('setTimeout 1');
  new Promise((resolve) => {
    console.log('promise 1');
    resolve();
  }).then(() => { console.log('promise then 1'); });
}, 500);
setTimeout(() => {
  console.log('setTimeout 2');
  new Promise((resolve) => {
    console.log('promise 2');
    resolve();
  });
}, 0);

结果分析:

  1. 初始化:两个 setTimeout 注册进入宏任务队列。setTimeout 2 延迟 0ms,排在前面。
  2. 第一轮循环:微任务队列空,取 setTimeout 2 执行。输出 setTimeout 2,创建包含 promise 2 的微任务。
  3. 第二轮循环:setTimeout 2 执行完毕,**强制检查微任务队列**。此时存在 promise 2 相关的微任务,必须将其全部执行完。输出 promise 2。
  4. 第三轮循环:微任务为空,取下一个宏任务 setTimeout 1。输出 setTimeout 1,接着创建 promise 1 微任务。
  5. 第四轮循环:再次检查微任务,执行 promise 1 及其 then。输出 promise 1、promise then 1。

结论:只要宏任务中产生了微任务,当前的宏任务结束后,必须立刻“清空”微任务队列,才会去执行后续的宏任务。

其他场景简述

  • 宏任务嵌套宏任务:新宏任务直接进入宏任务队列尾部,按照延迟时间和插入顺序排队。
  • 微任务嵌套微任务:新微任务会被推入微任务队列末尾,并在当前这一轮“清微任务”的过程中继续执行,不会中断去渲染或切换宏任务。

总结

JavaScript 的事件循环是管理并发行为的基石。开发者应牢记以下原则以规避异步 Bug:

  • 同步代码优先于一切异步代码。
  • 微任务 > 宏任务,且在当前宏任务结束后,必须先清空微任务队列才能进行下一轮循环。
  • 涉及 DOM 读取或修改的逻辑,若需依赖最新的 DOM 状态,应考虑放入微任务或确保在正确的宏任务周期执行。
  • 对于复杂的异步依赖,清晰梳理代码的执行路径,必要时使用 async/await 简化逻辑,但其底层依然遵循上述事件循环规则。

以上就是 JavaScript 事件循环、宏任务与微任务执行的详细内容,更多关于 JS 异步编程的资料请关注本站其它相关文章!

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表