☰
JavaScript 事件循环机制全解析:宏任务与微任务(浏览器与 Node.js)
2026/10/1 8:38:41 网站建设 项目流程
  • 文档
  • 教程
  • 前端

【免费下载链接】Web

千古前端图文教程,超详细的前端入门到进阶知识库。从零开始学前端,做一名精致优雅的前端工程师。

项目地址:https://gitcode.com/gh_mirrors/we/Web
点击查看免费下载

本文是「千古前端图文教程」中异步编程专题的核心章节,系统讲解 JavaScript 事件循环(Event Loop)的底层机制,以及宏任务(macrotask)、微任务(microtask)的分类与执行顺序。读完本文,你将理解为什么setTimeout(fn, 0)不会立刻执行、为什么 Promise 的then回调总是先于定时器回调执行,并能独立推导出各类"任务执行顺序"面试题的输出结果。文章同时结合 单线程和异步任务、Promise 入门详解、async 异步函数 等仓库文档,帮助你建立从"同步/异步任务"到"宏任务/微任务"再到"事件循环"的完整认知链。

前置认知:为什么会有事件循环?

在进入事件循环机制之前,必须先回答一个基础问题:JavaScript 为什么需要一套"循环"机制来调度任务?

JS 是单线程的

JavaScript 语言的执行是单线程的,即同一时间只能处理一个任务。具体来说,JS 引擎中负责解释和执行代码的线程只有一个,所有任务都需要排队,当前任务执行完后才能执行下一个。

JS 之所以被设计为单线程,主要有三个原因(详见 01-单线程和异步任务.md):

  • 历史原因:语言设计之初,多进程、多线程的架构并不流行,硬件支持也不好;
  • 复杂性原因:多线程操作需要加锁,编码复杂度会显著增高;
  • DOM 安全性原因:如果多个线程同时操作同一个 DOM,在多线程不加锁的情况下会产生冲突,导致渲染结果不符预期。

浏览器是多进程、多线程的

注意,"JS 是单线程的"指的是主线程只有一个。浏览器本身是多进程的,每打开一个新的 tab 标签页就会开启一个新的进程;每个进程中又有很多线程,其中专门有一个线程执行 JS 代码,这就是我们常说的"主线程"。

那么,耗时的任务(比如定时器计时、网络请求)会阻塞主线程吗?不会。耗时的任务并不是在主线程中执行的,而是交给浏览器进程中的其他线程协助处理,等时机成熟后再通知主线程执行。这个"通知—排队—执行"的调度过程,就是事件循环机制要解决的核心问题。

同步任务与异步任务

JS 将任务分为两类:

  • 同步任务:在主线程上排队执行,只有当前任务执行完毕才能执行下一个任务;
  • 异步任务:不进入主线程,而是进入任务队列(Event Queue)等待,无论是否得到结果都不会阻塞后续任务;只有任务队列通知主线程"某个异步任务可以执行了",该任务才会进入主线程执行。
console.log('同步任务1'); setTimeout(() => { console.log('异步任务'); }, 1000); console.log('同步任务2');

打印结果是:

同步任务1 同步任务2 异步任务

定时器回调作为异步任务,不会阻塞后面的同步代码。这正是异步编程的价值所在:凡是需要"等待"的场景(事件监听、定时器、ajax 请求、Node.js 的 FS 文件读写与数据库操作、ES6 的 Promise/async-await 等),都应采用异步方式(详见 01-单线程和异步任务.md 中的"前端使用异步编程的场景"一节)。

浏览器的事件循环机制

浏览器的事件循环,其执行顺序可以概括为以下三步:

  1. 同步任务:进入主线程后,立即执行;
  2. 异步任务:先进入 Event Table(事件注册表);等时间到了之后,再进入任务队列(Event Queue)排队。排队是因为同一时间 JS 只能执行一个任务,队列遵循**先进先出(FIFO)**原则。例如setTimeout(() => {}, 1000)这类定时器任务,需要等 1 秒之后才进入 Event Queue;
  3. 当主线程的任务执行完毕之后,主线程处于空闲状态,于是会去读取 Event Queue 中的任务队列;如果有任务,则取出来进入主线程执行。

这个过程不断重复,就形成了"事件循环"。用代码来验证:

console.log(1); setTimeout(() => { console.log(2); }, 0); console.log(3); console.log(4);

打印结果:

1 3 4 2

即使定时器延迟时间设为0,结果依然不变——setTimeout(() => {}, 0)的回调也要等同步任务全部执行完毕后才会执行。这正是事件循环机制的直接体现,也是面试中的高频考点。

值得补充的是:在浏览器中,setTimeout()/setInterval()每次调用定时器的最小时间间隔是4 毫秒(通常由函数嵌套达到一定深度,或已执行的 setInterval 回调阻塞导致)。所以setTimeout(() => {}, 1000)实际可能需要 1004 毫秒才会从 Event Table 进入 Event Queue(详见 01-单线程和异步任务.md 的"举例 3(较真系列)")。

Node.js 的事件循环机制

浏览器的 EventLoop 依据的是HTML5 规范,而Node.js 的 EventLoop 是由其底层 libuv 库规定的。libuv 是一个专注于异步 I/O 的跨平台库,Node.js 之所以能做到"轻量和高效",正是因为采用了基于事件循环的异步、非阻塞 I/O 模型——主线程只负责接收请求,长时间运行的任务(如 I/O、网络操作)交给背后的线程池处理,再通过队列和事件循环的机制执行(详见 02-Node.js的特点.md)。

Node.js 的事件循环中有六个队列,其中微任务有两个队列,宏任务有四个队列:

一、微任务队列

顺序队列名称典型任务
顺序 1next tick queueprocess.nextTick
顺序 2other queuePromise 的 then 回调、queueMicrotask

二、宏任务队列

顺序队列名称典型任务
顺序 3timer queuesetTimeout、setInterval
顺序 4poll queueI/O 事件
顺序 5check queuesetImmediate
顺序 6close queueclose 事件

也就是说,在 Node.js 中,process.nextTick的优先级高于 Promise 微任务;宏任务中则按 timer → poll → check → close 的顺序轮转。这是浏览器与 Node.js 事件循环的核心差异之一:浏览器按 HTML5 规范实现,Node.js 按 libuv 的阶段性轮转实现。

宏任务和微任务

JS 中的任务分为同步任务、异步任务;而异步任务又进一步分为宏任务(macrotask)和微任务(microtask)。

在早期,异步任务中只有宏任务,没有微任务。后来语言标准中推出了微任务,因为希望微任务能够尽早执行——微任务会在每个宏任务执行完毕后立即被清空,不必等待下一轮事件循环。

宏任务、微任务分类

事件循环的队列中,有两个队列:

1. 宏任务队列,包含这些任务:

  • ajax 网络请求;
  • setTimeout、setInterval;
  • DOM 事件;
  • UI 渲染;
  • I/O 文件读写操作。

2. 微任务队列,包含这些任务:

  • Promise 的 then 回调;
  • Mutation Observer API:监听 DOM 节点变化;
  • queueMicrotask():可直接将某个任务加入到微任务队列中。

特别注意 Promise 的调度时机:在执行一个 Promise 对象时,当走完resolve()进入 fulfilled 状态后,会立刻把.then()里面的代码加入到微任务队列当中。也就是说,then回调的"入队"动作发生在resolve()被调用的瞬间,而不是等到整段代码全部执行完。

在 Promise 的一些题目 中也有同样的验证:new Promise((resolve, reject) => { resolve(); console.log('promise1'); })中,Promise 构造器里的代码整体是同步任务,会立即执行;而resolve()之后的.then()回调是微任务,要等同步任务执行完之后才执行。

任务的执行顺序

JS 中任务的执行顺序是:同步任务 --> 微任务 --> 宏任务。

更精确的规则是:在执行任何一个宏任务之前(注意是"一个宏任务",不是"一个队列"),都会先查询微任务队列中是否还有任务需要执行:

  • 当前宏任务执行之前,必须要保证微任务队列是空的;
  • 如果微任务队列不为空,那就优先执行微任务队列中的任务。

这条规则意味着:微任务不会等到所有宏任务执行完才执行,而是"见缝插针"地插入到两个宏任务之间。理解这一点,是推导下面所有面试题结果的关键。

补充:为什么微任务能"尽早执行"

从实战角度看,微任务的这种调度特性非常有用。比如在 13-Promise的高级用法.md 中,封装一个延迟函数时,就是在宏任务(定时器)的执行过程中创建了一个微任务(resolve):

// 方法:XX秒后执行指定的代码。这个方法,就是在宏任务(定时器)的执行过程中,创建了一个微任务(resolve) function delaySeconds(delay = 1000) { return new Promise((resolve) => setTimeout(resolve, delay)); } delaySeconds(2000) .then(() => { console.log('qiangu'); return delaySeconds(3000); }) .then(() => { console.log('yihao'); });

打印结果:

// 2秒后打印: qiangu // 再等3秒后打印: yihao

定时器到达时间后进入宏任务队列执行resolve(),resolve()又会立刻把then回调推入微任务队列,由事件循环紧接着取出执行——宏任务与微任务就这样被串成了可靠的异步时序。

任务执行顺序的面试题精讲

实际开发中,基本不会出现下面这些题目,因为很多时候我们无法精准控制异步任务的执行顺序。但它们在面试中出现的频率特别高——熟悉这些思维训练,有利于考察对 JS 单线程、事件循环机制、宏任务和微任务等原理的掌握程度。下面四道题全部来自本文档,逐题给出代码、打印结果与逐步推演。

题 1:宏任务和微任务的执行顺序

setTimeout(() => { // 宏任务 console.log('setTimeout'); }, 0); new Promise((resolve, reject) => { resolve(); console.log('promise1'); // 同步任务 }).then((res) => { // 微任务 console.log('promise then'); }); console.log('同步任务'); // 同步任务

打印结果:

promise1 同步任务 promise then setTimeout

逐步推演:

  1. 遇到setTimeout,回调被注册为宏任务,先不执行;
  2. 进入 Promise 构造器:resolve()被调用后,立刻将then回调加入微任务队列;随后执行同步代码console.log('promise1'),打印promise1;
  3. 继续执行同步代码console.log('同步任务'),打印同步任务;
  4. 同步任务执行完毕,主线程检查微任务队列,执行then回调,打印promise then;
  5. 微任务队列清空后,才轮到宏任务setTimeout的回调,打印setTimeout。

上方代码执行的顺序依次是:同步任务 --> 微任务 --> 宏任务。

题 2:在宏任务中嵌套了微任务

new Promise((resolve, reject) => { setTimeout(() => { resolve(); console.log('setTimeout'); // 宏任务 }, 0); console.log('promise1'); }).then((res) => { // 微任务 console.log('promise then'); }); console.log('同步任务');

打印结果:

promise1 同步任务 setTimeout promise then

逐步推演:Promise 构造器中的setTimeout是同步注册的宏任务;console.log('promise1')是同步任务,先打印;随后打印同步任务。当定时器回调(宏任务)开始执行时,resolve()才被调用,此时才将then回调加入微任务队列——但按照规则,必须先把当前这个宏任务执行完(于是先打印setTimeout),再在下一轮去创建并执行微任务(打印promise then)。

代码解释:在执行宏任务的过程中,创建了一个微任务。但是需要先把当前这个宏任务执行完,再去创建并执行微任务。

题 3:综合题

console.log("script start") setTimeout(() => { console.log("setTimeout1"); new Promise(resolve => { resolve(); }).then(() => { new Promise(resolve => { resolve(); }).then(() => { console.log("then1"); }); console.log("then2"); }); }); new Promise(resolve => { // 下面这两行代码,即便调换顺序,也不影响打印结果 console.log("promise1"); resolve(); }).then(() => { console.log("then3"); }); setTimeout(() => { console.log("setTimeout2"); }); console.log('同步代码'); queueMicrotask(() => { console.log("queueMicrotask") }); new Promise(resolve => { resolve(); }).then(() => { console.log("then4"); }); console.log("script end");

打印结果:

// 第一次循环 script start promise1 同步代码 script end // 第二次循环 then3 queueMicrotask then4 // 第三次循环 setTimeout1 then2 then1 // 第四次循环 setTimeout2

逐步推演:

  • 第一次循环(同步任务):按代码书写顺序执行script start、promise1、同步代码、script end。期间两个setTimeout回调被注册为宏任务,两个 Promise 的then回调以及queueMicrotask回调被注册为微任务;
  • 第二次循环(微任务清空):主线程空闲后先执行微任务队列,按入队顺序依次打印then3、queueMicrotask、then4;
  • 第三次循环(第一个宏任务及其嵌套微任务):取出宏任务setTimeout1执行,打印setTimeout1;执行过程中resolve()触发新的微任务链:先打印then2(同步代码),再执行内层微任务打印then1;
  • 第四次循环(第二个宏任务):取出宏任务setTimeout2,打印setTimeout2。

可以看到,微任务在每两个宏任务之间被完整清空,宏任务之间则严格按入队先后顺序(先进先出)执行。

题 4:async/await 题目

async 函数(用async关键字声明的函数)的返回值永远是 Promise 对象(详见 10-async异步函数.md),await会暂停异步函数的执行并让出线程,等待 Promise 状态变为 fulfilled 后才继续往下走。因此 async/await 的执行顺序同样遵循"同步 --> 微任务 --> 宏任务"的规律。

代码举例:

console.log('script start') async function async2() { console.log('async2') } async function async1() { console.log('async1 start') await async2(); console.log('async1 end') } setTimeout(() => { console.log('setTimeout') }, 0) async1(); new Promise(resolve => { console.log('promise1') resolve(); }).then(function () { console.log('then1') }) console.log('script end');

打印结果:

script start async1 start async2 promise1 script end async1 end then1 setTimeout

逐步推演:

  1. 同步代码依次打印script start;注册setTimeout宏任务;
  2. 调用async1():打印async1 start,执行await async2()——async2()是同步执行并打印async2的,但await之后的console.log('async1 end')相当于被放进了微任务;
  3. Promise 构造器同步执行,打印promise1,then回调加入微任务队列;
  4. 打印script end,同步任务结束;
  5. 清空微任务队列:先打印async1 end(await 之后的代码),再打印then1;
  6. 最后执行宏任务,打印setTimeout。

这道题揭示了 async/await 与事件循环的衔接点:await之后的代码会被推迟到微任务阶段执行,这正是 async/await 本质上是 Generator 语法糖、内部由 Promise 驱动的体现。

总结:一张图记住执行顺序

事件循环机制的核心可以浓缩为以下规则:

  1. 同步任务先进主线程立即执行;
  2. 异步任务先进入 Event Table,时机成熟后进入 Event Queue(先进先出);
  3. 主线程空闲后,先清空微任务队列(Promise then、queueMicrotask、MutationObserver),再执行一个宏任务(setTimeout/setInterval、ajax、DOM 事件、I/O、UI 渲染);
  4. 执行每一个宏任务之前,都要保证微任务队列是空的,微任务若不为空则优先执行;
  5. 浏览器与 Node.js 的事件循环都遵循"微任务优先于宏任务"的总体方向,但实现细节不同:浏览器依据 HTML5 规范;Node.js 依据 libuv,微任务分为process.nextTick(next tick queue)与 Promise/queueMicrotask(other queue)两个队列,宏任务分为 timer、poll、check、close 四个队列。

掌握这套机制后,再看 06-Promise入门详解.md、10-async异步函数.md 中的异步代码,以及 11-异常处理方案.md 中的异步异常捕获,就能理解它们为什么会在特定时机执行。建议结合 Promise 的一些题目 中的多道执行顺序练习题反复推演,把"同步 --> 微任务 --> 宏任务"的执行顺序变成直觉。

  • 文档
  • 教程
  • 前端

【免费下载链接】Web

千古前端图文教程,超详细的前端入门到进阶知识库。从零开始学前端,做一名精致优雅的前端工程师。

项目地址:https://gitcode.com/gh_mirrors/we/Web
点击查看免费下载

相关推荐

上一篇:ncmdump终极指南:3步快速解锁网易云音乐NCM格式转换
下一篇:终极指南:如何用ncmdump轻松解密网易云音乐NCM格式文件

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询