news 2026/9/23 16:30:44

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解JavaScript事件循环:宏任务与微任务的执行顺序

你打开浏览器控制台,敲下这三行代码,先别急着看答案,在心里默念一遍输出顺序:

console.log('start'); setTimeout(() => console.log('timeout')); Promise.resolve().then(() => console.log('promise'));

我太熟悉这道题了。几乎每次面试前端候选人,我都会用它开场,结果很多人就是卡在promisetimeout谁先输出上。有人说timeout先,因为代码里它写在前面;有人说是promise先,因为 Promise 比 setTimeout 快。速度根本不是关键,关键是大部分人压根没搞懂事件循环里有两种队列——宏任务队列和微任务队列,而Promise.then塞进去的就是微任务。

作为一个在前端圈子摸爬滚打了十多年的老开发,我今天就把“微任务到底是个啥”这件事,用最直白的话给你讲透。读完这篇,你再碰到这类面试题、或者项目里遇到 setTimeout 和 Promise 混在一起导致的诡异 bug,都能一眼看穿。

1. 事件循环是怎么转的:理解微任务的前提

1.1 JavaScript为什么只能单线程干活

很多人一开始学前端就会背一句话:“JavaScript 是单线程的。”但为什么要设计成单线程,很少有人说清楚。

说白了就一个原因:JavaScript 主要工作在浏览器里,它要操作 DOM。假设有两个线程,一个线程在给某个按钮绑定点击事件,另一个线程正在把这个按钮从页面上删掉,你让浏览器听谁的?页面状态该怎么同步?为了不让 DOM 操作陷入这种“两个人在改同一份文档”的混乱,浏览器干脆规定:所有 JS 代码都在同一个线程上排队执行,一次只干一件事。

这有点像只有一个收银员的便利店。不管来了多少顾客,都得排队,收银员一次只能服务一个人。但这也带来一个问题:如果某个顾客一直站着不走(比如代码里有个死循环),后面所有人都得等着。为了避免这种“一个人堵死一家店”的局面,浏览器就设计了异步机制——把一些不需要立刻完成的活儿,先“记账”下来,等主线程忙完了再去处理。

这里的“记账”就是任务队列。而异步任务又分成了两种,这就是微任务和宏任务概念的来源。

1.2 事件循环:主线程在“抽空排队”

事件循环(Event Loop)这个名字听起来高级,其实特别接地气。主线程从上往下执行代码,遇到同步代码立刻执行,遇到异步操作就交给浏览器其他模块(比如定时器模块、网络模块)去处理,自己继续往下走。等主线程的“执行栈”清空了,它才会去看看有哪些异步任务已经准备好了,把它们拿过来执行。

用之前的便利店类比:收银员(主线程)先专心服务当前顾客(同步代码),同时后台的进货工人(浏览器其他线程)把新货摆上货架(把异步任务放进队列)。等当前顾客结完账,收银员才抬头看看货架上有什么要补的。

那问题来了:货架上摆着两种货——宏任务和微任务。先处理哪个?不同品牌的“便利店”规则不一样,但浏览器和 Node.js 都遵循同一条规则:每个宏任务执行完之后,必须马上把微任务队列清空,再回到事件循环取下一个宏任务

看到没?微任务像是有插队特权。不是随机插,而是固定插在每个宏任务结束后的那个瞬间。搞懂这一点,后面所有执行顺序题都是送分题。

1.3 渲染时机:宏任务、微任务与浏览器的配合

小程序,哦不,普通前端开发里还有个容易忽略的角色——渲染。浏览器并不是每执行完一行 JS 就立刻把页面画一遍,那样性能早崩了。浏览器有自己的节奏:通常会等执行的 JS 告一段落,把这一阶段该更新的 DOM 变化合并起来,一次性完成布局和绘制。

这个“告一段落”的点,恰恰就是当前宏任务和全部微任务执行完毕之后。也就是说,宏任务和微任务里对 DOM 的修改,通常会在同一帧渲染里体现出来,而不是改一处渲染一次。这也是为什么微任务被称为“微”任务——它轻量、快速、在当前宏任务收尾前就被消化掉,不会拖延渲染时机。

记住这个时机点,后面我在第 4 章讲“微任务引发的真实渲染坑”时会再用到它。

2. 先分清两类任务:宏任务 vs 微任务

2.1 常见的宏任务都有哪些

宏任务(MacroTask,也常被叫 Task)是事件循环的基本工作单元。每轮事件循环,浏览器或 Node 只取出一个宏任务来执行。常见的宏任务有这么几类:

  • setTimeoutsetInterval的回调
  • I/O 操作(文件读写、网络请求完成回调)
  • UI 交互事件(click、keydown、scroll 等)触发的回调
  • 消息事件(MessageChannel触发)
  • 页面渲染用的requestAnimationFrame(也有人把它单独归类,但理解成宏任务维度的东西没毛病)
  • Node.js 里的setImmediate

重点说setTimeout。很多人以为setTimeout(fn, 0)就是“0 毫秒后立刻执行”,其实这是天大的误会。浏览器底层最快时钟粒度一般不是 0,通常会被限制在 4ms 左右甚至更高,而且即使真的到了时间点,这个回调也只是被放进宏任务队列,得等当前执行栈和其他微任务全部清空,主线程才会把它拿出来执行。

2.2 常见的微任务都有哪些

微任务可以理解成“当前宏任务结束后、下一次宏任务开始前,必须清空的高优先级小任务”。常见的微任务来源:

  • Promise.then/catch/finally注册的回调
  • async函数中await之后的代码
  • queueMicrotask(fn)手动注册的微任务
  • MutationObserver触发的回调
  • Node.js 里的process.nextTick(严格来说是比微任务更靠前的存在,这里不再展开)

这些操作有个共同点:它们都是由当前代码触发、希望尽快执行完的后置逻辑。比如 Promise 的状态已经变了,then 里的回调如果不尽快执行,可能影响后面一堆依赖它的逻辑。

2.3 微任务为什么能“夹塞”:两条队列的优先级规则

理解了来源,我们再抠一层:为什么微任务有资格“夹塞”?这要回归到设计意图。

Promise的出现是为了解决回调地狱,让异步代码能以同步的思维来书写。如果Promise.then的回调被当成宏任务,排到 setTimeout 后面,那用户会看到一种非常割裂的体验:明明 Promise 的数据已经准备好了,页面里依赖它的逻辑却迟迟不执行,中间可能还会插入乱七八糟的 UI 交互。所以规范强制要求:每个宏任务执行完,引擎会检查微任务队列,把里面所有已就绪的回调一次性执行完;执行微任务过程中如果又新增了微任务,也会在本轮继续执行,直到微任务队列清空为止。

想象一下便利店收银台有个 VIP 通道,规则是:普通叫号(宏任务)叫一个人进来结账后,必须把 VIP 通道里等待的人全部服务完,才能叫下一个普通号。新来的 VIP 也会立刻被服务,哪怕普通号队伍前面还排着几十个人。这就是微任务的“夹塞”资格。

这里我要特意提醒一个词:“所有”。微任务和宏任务不一样,宏任务每轮只取一个,微任务每轮是清空整个队列。这也是许多老手都会忽略的细节——以为微任务也像宏任务那样一个一个来,结果在微任务里递归丢 Promise,直接把页面卡死。这个坑我等下实操章节会专门讲。

对比项宏任务(Task)微任务(Microtask)
常见生产者setTimeout、I/O、UI 事件、setIntervalPromise.then、await、queueMicrotask、MutationObserver
每轮事件循环取几个1 个全部清空
什么时候执行主线程执行栈空,且微任务清空后每个宏任务结束后,立即执行
优先级
典型用途定时、网络、用户交互Promise 链、异步状态更新

3. Promise.then 与微任务:这次把它彻底捋直了

3.1 你踩过的坑:Promise执行器是同步的,then才是异步的

先纠正一个常见的误区:new Promise(executor)里的那个 executor(执行器函数)是同步执行的,而.then里的回调才是异步的。

console.log('a'); new Promise((resolve, reject) => { console.log('b'); resolve(); }); console.log('c');

这段代码输出什么?a b c。很多人第一次跑的时候会愣一下:Promise 不是异步的吗,为什么b是同步打印的?因为 executor 是构造器的一部分,它要当场执行,目的是让你在函数体里发请求、初始化状态、确定这个 Promise 最终是 resolve 还是 reject。只有状态确定(resolve 或 reject 被调用)后,.then注册的回调才会被安排进微任务队列。

所以记住一句话:Promise 的 executor 是“现在”,then 是“稍后”。这个“稍后”不是 setTimeout 那种甩到下一个宏任务,而是甩进微任务队列,在当前宏任务收尾时立刻执行。

3.2 多级then和嵌套Promise:到底谁先谁后

理解了 then 进微任务队列,我们再来看稍微复杂点的情况:多级 then 和嵌套 Promise。

Promise.resolve() .then(() => { console.log('1'); }) .then(() => { console.log('2'); });

输出1 2,这个简单。但换一种写法:

Promise.resolve() .then(() => { console.log('1'); Promise.resolve().then(() => console.log('2')); }) .then(() => { console.log('3'); });

输出是1 2 3。来走一遍:外层第一个 then 的回调进入微任务队列,同时第二个 then 注册的后续回调也已经在链上等着了。事件循环进入清空微任务阶段,执行第一个回调:

  • 打印1
  • 遇到Promise.resolve().then(...),把打印2的回调追加到微任务队列末尾
  • 第一个回调执行完,返回 undefined,这会触发外层 Promise 的 resolve,于是第二个 then 的回调(打印3)也被追加到微任务队列末尾

此时微任务队列里排着23,按先进先出,打印2,再打印3

为什么3不直接在1后头?因为第二个 then 依赖第一个 then 返回的 Promise,必须等第一个回调执行完毕并返回,新的 Promise 状态才确定,它的回调才有资格入队。这就是 Promise 链的“链式等待”。

3.3 async/await:语法糖背后的微任务细节

async/await是老生常谈的语法糖了。await后面的代码,本质上就是Promise.resolve(表达式).then(...)的“语义化版本”。但这里面有个非常隐蔽的细节,很多面试官自己都未必讲得清。

async function async1() { console.log('a'); await async2(); console.log('b'); } async function async2() { console.log('c'); } console.log('start'); async1(); Promise.resolve().then(() => console.log('d')); console.log('end');

你猜输出顺序?我这里直接给结论:start a c end d b

注意,async2()里的代码是同步执行的,所以先打印ac。然后await async2()把后面的console.log('b')包装成微任务丢进队列。紧接着Promise.resolve().then(...)又把打印d的回调丢进队列。按照“先进先出”,b应该先入队,d后入队,所以传统认知里输出应该是bd前面。

但现代 V8 引擎(Chrome 和 Node 的主流引擎)做了优化:await当接受一个“已经是原生 Promise 的值”时,后续代码的入队时机可能比普通Promise.resolve().then()稍有延迟——实际表现常常是d先打印,b后打印。

这一类边界情况在不同版本引擎里可能有差异,我对前端团队的要求是:能推导 90% 的大众场景即可,剩下的 10% 靠实测,别背答案。真正实用的是前面 3.2 节的“链式等待”模型,那才是万变不离其宗的根。

4. 面试题、实战题拆解:看完就能做对

4.1 经典送分题逐行推演

我们回到开头那道题,把它扩展成面试官最爱问的完整版本:

console.log('script start'); setTimeout(function () { console.log('setTimeout'); }, 0); Promise.resolve() .then(function () { console.log('promise1'); }) .then(function () { console.log('promise2'); }); console.log('script end');

完整输出是:script start → script end → promise1 → promise2 → setTimeout

逐段推演:

  1. 同步代码从上往下,先打印script start
  2. 遇到setTimeout,把它丢进宏任务队列,不执行
  3. 遇到Promise.resolve().then(...),把打印promise1的回调丢进微任务队列;第二个.then因为要等第一个回调执行后才能确定返回的 Promise 状态,所以此时还没入队
  4. 打印script end
  5. 当前宏任务代码全部执行完,开始清空微任务队列:执行回调打印promise1,执行完后触发外层 Promise resolve,打印promise2的回调入队,再执行,打印promise2
  6. 微任务队列清空后,回头取宏任务队列,打印setTimeout

这就是事件循环的完整闭环。你按这个套路去分析其它题,基本不会跑偏。

4.2 变体一:嵌套setTimeout

把两个 setTimeout 嵌套起来,熟练度可以瞬间区分开:

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

输出是:timer1 → promise1 → timer2 → promise2

原因:第一个 setTimeout 回调作为一个宏任务执行,先打印timer1,然后把打印promise1的微任务入队。该宏任务刚执行完,立即清空微任务,打印promise1。只有微任务队列空了,才轮到第二个宏任务timer2,它的后续微任务promise2也一并清掉。

这个例子非常能说明“每取一个宏任务,清空一次微任务”这个规则:不是所有宏任务执行完了再统一清微任务,而是一个宏任务配一轮微任务清空

4.3 变体二:async函数里的await

再给一个常被问的 async 变体:

async function test() { console.log('1'); await new Promise((resolve) => { setTimeout(resolve, 0); }); console.log('2'); } test(); setTimeout(() => console.log('3'), 0);

输出是:1 3 2

test()被调用时,同步代码先执行到console.log('1')await后面的new Promise(...)内部 executor 是同步的,它执行setTimeout(resolve, 0),把 resolve 作为宏任务丢进队列;await后面的打印2必须等 resolve 之后才能入微任务队列。外层宏任务继续向下,碰到setTimeout(() => console.log('3'), 0),把打印3丢进宏任务队列。

第一轮宏任务结束后,微任务队列为空。第二轮先执行最早入队的宏任务——setTimeout(resolve, 0)的 resolve,它让await后面的console.log('2')入队微任务,此时事件循环会立刻把这个微任务清空,打印2。第三轮才轮到打印3

如果没理解透“await 后面的代码是微任务”,这道题很容易答成1 2 3,那对前面 setTimeout 的先后就全乱了。

4.4 开发里真正要命的微任务坑

面试题会了,我更想让你记住开发里的几个真坑。

坑一:微任务里递归导致页面卡死。代码长这样:

function loop() { Promise.resolve().then(loop); } loop();

这比while(true)还狠。while(true)至少还在同一个宏任务里,浏览器还有机会通过某些手段终止;微任务递归会让微任务队列永远清不空,浏览器永远没机会进入“取下一个宏任务 → 渲染页面”的环节,结果就是页面彻底假死,白屏转圈,连点击事件的回调都排不上号。我在真实项目里见过同事在then里不小心形成了自循环,排查的时候看 Performance 面板全是 Microtasks,才意识到问题出在哪。

坑二:微任务改 DOM 引发的渲染误判。开头我说过,浏览器会等当前宏任务和微任务都清空后再统一渲染。有人会以为在微任务里改 DOM 能“立刻”生效,于是写代码依赖这一步,结果发现拿到的样式值还是旧的。

button.addEventListener('click', () => { Promise.resolve().then(() => { document.body.style.background = 'red'; console.log(document.body.style.background); // 有时仍是老值 }); });

因为在样式计算和渲染真正发生之前,style的某些读取可能会触发同步的重算,但也可能因为浏览器优化策略呈现旧状态。这属于“跨任务/跨帧”导致的时序问题。我的建议是:别依赖微任务和渲染之间的边界,需要读最新样式就放到requestAnimationFrame里,那个时机离渲染更近。

坑三:第三方库混合使用同步和异步引起状态错乱。比如某个 request 库内部用 Promise,外层却有人用setTimeout保底超时。这两者混在一个事件循环里,如果 SDK 内部的微任务还没跑完,超时宏任务触发了,可能报出“response 未定义”之类的错。这时候你要往“宏任务微任务交错执行”的方向排查,别老盯着数据源。

5. 实操方法论:再也不被绕晕的判定流程

5.1 三行口诀,随时默念

我总结了一套极其朴素的判定口诀,团队里新人都靠它上手:

“同步先跑;Promise、await 后门排队;每个宏任务收工,先清微任务再叫下一个。”

用这个口诀去套题,基本两三秒就能出结果。你不用背复杂的 ECMAScript 规范,你只需要记住事件循环的几层顺序:

  1. 执行当前宏任务内的所有同步代码
  2. 执行微任务队列里的全部回调(执行过程中新加入的微任务也一并执行)
  3. 浏览器有机会执行渲染(如果有需要)
  4. 取下一个宏任务,重复以上步骤

这个顺序是万能的。面试拿到代码题,先在草稿纸上画三个区域:宏任务队列、微任务队列、输出结果串。然后一条语句一条语句过,把对应的回调丢进各自的区域,最后按顺序读输出。

5.2 调试工具和现场标记法

纸上推演始终是理论,项目里碰到真实问题时,我推荐两种高效的调试方式。

第一种:console.log 标记法。在对应的宏任务和微任务回调入口塞日志,带上前缀:

console.log('[sync] script start'); setTimeout(() => console.log('[macro] timeout')); Promise.resolve().then(() => console.log('[micro] promise'));

日志顺序直接告诉你真实世界的事件循环顺序。虽然土,但最直接,也算顺带验证了你的推断。

第二种:DevTools Performance 面板。打开 Performance 面板录制,然后在控制台跑一段混合了宏任务和微任务的代码,录制结束后看主线程时间线。宏任务会呈现为较宽的 Task 块,微任务会以更小的块或标记穿插在宏任务尾部和下一次 Task 之间。这个观察非常直观,能帮助建立“微任务是夹在两个宏任务之间的细碎活”的空间感。

第三种:Node 命令行实测。如果你主要写 Node,直接在终端用node跑脚本,把结果打出来对照。Node 和浏览器的事件循环大体一致,但process.nextTicksetImmediate的顺序有自己的规则,需要单独记。前端浏览器场景不建议用这两者,统一用queueMicrotask就行。

5.3 我用过的避坑清单

以下几条可以说是我这十多年写异步代码的“血泪债”总结,今天全给你:

场景看起来对实际推荐
需要把逻辑推迟到当前任务之后setTimeout(fn, 0)queueMicrotask 或 Promise.resolve().then()
需要对 DOM 做连续修改并读取在微任务里改完立刻读让出到 requestAnimationFrame 再读
需要在微任务里做轮询递归 Promise改用 setInterval / setTimeout 轮询,避免微任务队列被占满
要控制多个异步串行Promise 链 + async 混用先统一风格,尽量用 async/await 表达串行逻辑
要高优先级处理链路状态宏任务里反复检查状态把状态更新放到微任务,确保当前任务结束前状态一致

这里面最容易被忽视的是第一条。很多人一看到 setTimeout(fn, 0) 就当作“异步神器”,其实大多数场景下,微任务才是更贴近“我代码写完了,马上处理后续”语义的机制。刻意用 setTimeout 去推迟,不仅慢,还容易把执行顺序搞乱。

写在最后

我个人在实际带团队和做 code review 时最深的感触是,事件循环和微任务这套东西,最难的从来不是背规则,而是形成条件反射:看到 Promise,先想微任务;看到 setTimeout,先想宏任务;看到 async/await,立刻画一条时间线出来,逐行走一遍再动手写代码。说实话,面试题里那些“输出什么”的题目,做完也就是图一乐。真正值钱的是你带着这套模型去调试线上问题的时候,能比同事快一步定位到“哦,是微任务队列没清空/提前被插入”的根因。

最后再送一个小技巧:写复杂异步逻辑时,尽量不要让同一段代码里同时出现宏任务和微任务两套计时机制。如果非混用不可,就在代码注释里写清楚“这一行进宏任务队列,后面两行进微任务队列”,省得两周后你自己回来看代码都懵。电脑不会骗你,会骗你的永远是没捋清的队列。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 16:29:40

Windows原生PDF页码统计工具:精准获取物理页数

简介:这是一款面向Windows平台用户的PDF页码批量统计工具,适用于文档管理、归档审核、出版排版等需快速获取PDF页数的办公与开发场景,尤其适合非编程背景的行政、编辑及初级Python学习者。资源包共5个文件,含2个测试PDF样本&#…

作者头像 李华
网站建设 2026/9/23 16:29:37

AZ-104备考指南:从考纲拆解到命令行实战的全流程攻略

简介:AZ-104 是微软 MCP 认证体系中面向 Azure 管理员方向的官方考试,这份 PDF 题库专为计划考取该认证的 IT 运维、云架构师及企业 Azure 使用者精心准备。整个压缩包仅含 1 个 PDF 文件,包体约 45.55MB,题目按 Topic 内容清晰分…

作者头像 李华
网站建设 2026/9/23 16:29:09

Claude Code OpenAI兼容与DeepSeek工具链集成实战指南

1. 标题里的“格式投降”不是妥协,是工程现实的主动选择“Claude Code下半年:格式投降OpenAI,功能借鉴DeepSeek”——这个标题乍看像一句调侃,实则精准戳中了当前本地大模型开发工具链演进的核心矛盾:协议兼容性比模型…

作者头像 李华
网站建设 2026/9/23 16:28:35

海康威视IPC+OpenCV人体检测实战:从拉流到部署的全链路避坑指南

简介:本资源是一套基于海康威视网络摄像头与OpenCV实现人体识别的完整C工程,适用于计算机视觉方向的本科毕业设计、课程设计及项目实践,面向具备C基础与OpenCV入门经验的学习者。项目采用HOGSVM等传统机器学习方法进行人体检测,包…

作者头像 李华
网站建设 2026/9/23 16:28:19

Ontology(本体)怎样工作?RDF、OWL、SPARQL、SHACL 各管什么

上面这张图,先把本文要讲的事说完了。 同一张售后工单,会依次遇到四类问题:事实怎么表达,规则怎么推理,结果怎么查出来,当前数据够不够进入下一步。很多 Ontology 文章会从 RDF、OWL、SPARQL、SHACL 的定义…

作者头像 李华
网站建设 2026/9/23 16:27:34

高考志愿填报参考系统Java源码包:Spring Boot+MyBatis实现位次换算与梯度推荐

简介:这份Java高考志愿填报参考系统源码面向具备一定Java基础、希望了解完整Web应用架构的学生与开发者,可用于课程设计、毕业设计参考或自学练手。系统围绕考生成绩、兴趣与就业前景提供志愿填报建议,涵盖用户管理、院校专业信息、录取结果与…

作者头像 李华