你打开浏览器控制台敲下这三行代码先别急着看答案在心里默念一遍输出顺序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 事件、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也被追加到微任务队列末尾此时微任务队列里排着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立刻画一条时间线出来逐行走一遍再动手写代码。说实话面试题里那些“输出什么”的题目做完也就是图一乐。真正值钱的是你带着这套模型去调试线上问题的时候能比同事快一步定位到“哦是微任务队列没清空/提前被插入”的根因。最后再送一个小技巧写复杂异步逻辑时尽量不要让同一段代码里同时出现宏任务和微任务两套计时机制。如果非混用不可就在代码注释里写清楚“这一行进宏任务队列后面两行进微任务队列”省得两周后你自己回来看代码都懵。电脑不会骗你会骗你的永远是没捋清的队列。