你打开浏览器控制台,敲下这三行代码,先别急着看答案,在心里默念一遍输出顺序:
console.log('start'); setTimeout(() => console.log('timeout')); Promise.resolve().then(() => console.log('promise'));我太熟悉这道题了。几乎每次面试前端候选人,我都会用它开场,结果很多人就是卡在promise和timeout谁先输出上。有人说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 只取出一个宏任务来执行。常见的宏任务有这么几类:
setTimeout、setInterval的回调- 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 事件、setInterval | Promise.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)也被追加到微任务队列末尾
此时微任务队列里排着2和3,按先进先出,打印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()里的代码是同步执行的,所以先打印a和c。然后await async2()把后面的console.log('b')包装成微任务丢进队列。紧接着Promise.resolve().then(...)又把打印d的回调丢进队列。按照“先进先出”,b应该先入队,d后入队,所以传统认知里输出应该是b在d前面。
但现代 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。
逐段推演:
- 同步代码从上往下,先打印
script start - 遇到
setTimeout,把它丢进宏任务队列,不执行 - 遇到
Promise.resolve().then(...),把打印promise1的回调丢进微任务队列;第二个.then因为要等第一个回调执行后才能确定返回的 Promise 状态,所以此时还没入队 - 打印
script end - 当前宏任务代码全部执行完,开始清空微任务队列:执行回调打印
promise1,执行完后触发外层 Promise resolve,打印promise2的回调入队,再执行,打印promise2 - 微任务队列清空后,回头取宏任务队列,打印
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 规范,你只需要记住事件循环的几层顺序:
- 执行当前宏任务内的所有同步代码
- 执行微任务队列里的全部回调(执行过程中新加入的微任务也一并执行)
- 浏览器有机会执行渲染(如果有需要)
- 取下一个宏任务,重复以上步骤
这个顺序是万能的。面试拿到代码题,先在草稿纸上画三个区域:宏任务队列、微任务队列、输出结果串。然后一条语句一条语句过,把对应的回调丢进各自的区域,最后按顺序读输出。
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.nextTick和setImmediate的顺序有自己的规则,需要单独记。前端浏览器场景不建议用这两者,统一用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,立刻画一条时间线出来,逐行走一遍再动手写代码。说实话,面试题里那些“输出什么”的题目,做完也就是图一乐。真正值钱的是你带着这套模型去调试线上问题的时候,能比同事快一步定位到“哦,是微任务队列没清空/提前被插入”的根因。
最后再送一个小技巧:写复杂异步逻辑时,尽量不要让同一段代码里同时出现宏任务和微任务两套计时机制。如果非混用不可,就在代码注释里写清楚“这一行进宏任务队列,后面两行进微任务队列”,省得两周后你自己回来看代码都懵。电脑不会骗你,会骗你的永远是没捋清的队列。