JavaScript事件循环深度解析:宏任务与微任务的执行顺序
很多前端开发者在背执行顺序的时候都听过一句口诀“同步先走然后是微任务最后才是宏任务。” 但真到了写代码的时候只要把Promise、setTimeout、async/await和事件处理混在一起控制台实际打出来的顺序往往和口诀差得十万八千里。我自己就栽过跟头当时为了排查一个页面数据更新时序问题花了大半天最后才发现问题根本不在于数据逻辑而是对事件循环中的宏任务与微任务执行机制理解不透。所以这篇内容决定从头到尾把 JavaScript 事件循环拆开重点讲清楚宏任务和微任务的调度优先级、浏览器渲染插入的时机以及 Node.js 环境下的差异和实战排错方法。不管你是初级开发还是准备深入前端面试只要你写过Promise、被setTimeout的顺序迷惑过这篇文章就是给你的。1. 事件循环到底在调度什么调用栈、宏任务与微任务的边界1.1 调用栈清空之前任务队列永远轮不到执行JavaScript 是一门单线程语言这个“单线程”指的是同一时刻只能执行一段代码。引擎在执行代码时会维护一个调用栈call stack同步代码一条条压栈执行执行完立刻弹栈。而setTimeout、Promise、事件回调这类异步逻辑并不会直接进入调用栈而是要等某个条件满足后再把对应的回调放进任务队列。事件循环做的工作就是不断检查两件事调用栈是不是已经空了任务队列里有没有需要执行的回调只有当调用栈清空后事件循环才会从任务队列里取一个任务交给调用栈执行。这里有一个很多人会误解的地方异步并不是“同时在后台跑另一段 JS 代码”。以setTimeout(0)为例它只是告诉了浏览器“你至少要在这么多毫秒之后把这个回调放进宏任务队列”至于回调什么时候真正执行还要看当前同步代码跑多久、队列里排了多少个任务。就算我把延迟时间设为 0它也永远不可能插到当前这段同步代码中间去。console.log(a); setTimeout(() { console.log(b); }, 0); console.log(c); // 输出 a c b这个结果大家都见过但真正需要理解的是setTimeout的回调被放进了宏任务队列事件循环必须等同步代码console.log(a)和console.log(c)都执行完调用栈空了才能把回调取出来执行。所以b排到了最后。1.2 宏任务和微任务不是同一条队列宏任务和微任务被执行引擎放在不同的队列里这一点如果不清楚后面的所有推演都会乱套。浏览器里常见的宏任务包括整个 script 代码块、setTimeout、setInterval、I/O 事件回调、UI 点击事件回调、MessageChannel等。常见的微任务则包括Promise.then/catch/finally的回调、queueMicrotask、MutationObserver的回调。两条队列的优先级规则很霸道每执行完一个宏任务事件循环会先把微任务队列里的所有任务从头到尾执行干净然后才去宏任务队列里取下一个宏任务。而且“所有”这两个字很重要如果在执行微任务的过程中又有新的微任务被塞进队列这个新的微任务也会在同一轮清空操作里被执行直到队列彻底为空。正因为规则如此浏览器和 Node.js 都默认微任务可以“插队”这种插队不是语言层面的魔法而是事件循环在每次宏任务结束之后强制清空微任务队列的结果。1.3 用餐厅叫号类比微任务为什么能插队如果觉得队列概念太抽象可以用一个生活化场景来记宏任务队列是餐厅门口的叫号队列微任务队列是你在买单时同桌朋友临时拜托你顺手带的小请求。餐厅叫到你的号之后你走进门开始点菜、买单此时朋友们不断往你手里塞小纸条“帮我问下有没有靠窗位”“帮我要个打包盒”。店员不会让你手里拿着这些纸条、背着没处理完的小请求去叫下一个号而是要求你把手头这一堆小请求都处理完才会叫下一个宏任务的号。所以微任务永远比下一个宏任务先执行不是它更“重要”而是事件循环的规则就是这样当前宏任务结束先清空微任务然后才轮到下一个宏任务。这个类比每次在我需要快速向别人解释执行顺序时都很管用。2. 宏任务与微任务的执行顺序一个完整循环的切面2.1 标准循环的四个关键步骤想真正看懂宏任务与微任务的机制可以抛开浏览器和 Node 的实现差异先把一个标准事件循环抽象成四个步骤从宏任务队列里取出一个任务来执行。第一次循环时这个任务通常就是整个 script 模块的同步代码。让这个任务一直执行到调用栈清空中间产生的所有微任务全部进入微任务队列。检查微任务队列把里面排队的所有微任务依次执行完毕。执行过程中如果又产生新的微任务也继续执行直到清空。根据需要决定是否进行浏览器渲染然后回到第 1 步取出下一个宏任务。这个模型里微任务队列的执行时机是“当前宏任务执行结束后”而不是“所有宏任务执行完后”。因为每个宏任务结束都会做一次清空操作所以执行顺序本质上是“宏任务 → 微任务清空 → 宏任务 → 微任务清空”这样交替推进的。2.2 一段混合代码逐行推演理论说了半天不如直接推演一段真实代码。下面这段里既有两个setTimeout也有一个连续两个.then的PromisesetTimeout(() { console.log(setTimeout-1); }, 0); setTimeout(() { console.log(setTimeout-2); Promise.resolve().then(() { console.log(promise-inside-timeout); }); }, 0); Promise.resolve() .then(() console.log(promise-1)) .then(() console.log(promise-2));输出结果是promise-1 promise-2 setTimeout-1 setTimeout-2 promise-inside-timeout推演过程是这样的同步代码执行时先注册两个setTimeout宏任务再注册一个两次.then的微任务链。第一个宏任务也就是 script 本身执行结束后事件循环去清空微任务队列于是promise-1先输出之后第一个.then返回的 Promise 又立刻往微任务队列里追加了promise-2所以promise-2紧接着输出。此时微任务队列空了才去宏任务队列取出setTimeout-1执行。接着取出setTimeout-2输出之后它内部又注册了一个 Promise 微任务这个微任务必须等当前宏任务结束、调用栈清空后才执行因此排到了setTimeout-2后面。这个例子很典型因为它揭穿了一个误区不是“所有微任务永远排在所有宏任务前面”。promise-inside-timeout是在第二个宏任务执行过程中产生的新微任务它会被安排到第二个宏任务结束后执行而不是排到第一个宏任务前面。2.3 别背口诀交替才是常态我在看很多人写的异步顺序分析时发现他们喜欢用“微任务一定比宏任务先执行”这句话去套所有场景结果遇到嵌套宏任务里的 Promise 就卡壳。正确的理解方式是把整个流程当成一个不断重复的切片宏任务执行微任务清空宏任务执行微任务清空。每一次“宏任务 微任务清空”就是一个事件循环的切面。只要能画出当前执行至哪个宏任务再画出这个宏任务产生过哪些微任务顺序就不会算错。想靠一句话背下来所有嵌套场景很难但理解“微任务清空是针对当前这次宏任务而言的”大部分输出结果都能够预测。3. Promise与async/await的时序陷阱3.1 Promise构造函数是同步的then回调才是微任务Promise相关的坑第一个就是很多人误以为new Promise里的代码是异步的。实际上Promise 构造函数里注册的executor函数是立即同步执行的只有.then、.catch、.finally里的回调才会被放入微任务队列。下面这段代码的输出顺序就很直观console.log(start); new Promise(resolve { console.log(inside promise); resolve(done); }).then(message console.log(message)); console.log(end);输出结果start inside promise end doneinside promise出现在end之前说明 Promise 内部代码没有异步化。真正异步的是.then里的回调它在调用栈清空后的微任务阶段才执行所以done最后打印。这个基础概念如果不顺手后面读写async代码很容易把同步部分当作异步处理。3.2 await后面的代码会被打包成微任务async/await是Promise的语法糖但它比直接写Promise.then更容易让人误判执行时机。关键点有两个第一await右侧的表达式会立即执行第二await之后的代码会像微任务一样排队执行。看这个例子async function demo() { console.log(before await); await Promise.resolve(ok); console.log(after await); } console.log(start); demo(); console.log(end);输出结果start before await end after awaitdemo()被调用后同步打印before await然后执行到await右侧的Promise.resolve(ok)只是一个立即返回已解决 Promise 的表达式。此时demo函数会在await处暂停把后面的console.log(after await)作为一个微任务注册到队列里先返回调用位置。所以end会排在after await前面。有些朋友下意识认为await会“阻塞整段代码”这是完全错误的。它只阻塞当前async函数内部后续代码的执行不会阻塞外部同步代码。3.3 一道经典面试题的完整输出推演把async/await和Promise放在一起是面试中最高频的顺序题。下面这段代码我推演过很多次用它来检验自己对微任务注册时机的理解很合适async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); async1(); new Promise(resolve { console.log(promise create); resolve(); }).then(() { console.log(promise then); }); console.log(script end);完整输出顺序是script start async1 start async2 promise create script end async1 end promise then setTimeout逐步推演script start是同步输出接着注册setTimeout宏任务。然后调用async1()它会同步打印async1 start执行到await async2()async2()内部同步打印async2并返回一个已解决状态的 Promise。此时async1函数暂停把后续的console.log(async1 end)注册为微任务。注意这个微任务的注册时机比下面的 Promise 构造函数更早因为new Promise的 executor 是在async1()执行完之后才开始执行的所以.then回调注册得晚了一步。继续执行new Promise构造函数同步打印promise create并立刻resolve()于是.then回调也被注册成微任务排在async1 end后面。同步代码最后打印script end。当调用栈清空微任务队列里已经有两个任务async1 end和promise then。先输出async1 end再输出promise then。直到微任务队列清空才轮到宏任务队列里的setTimeout。很多人做错这道题通常是把await async2()后面的代码当成了同步执行或者误以为new Promise里的代码是异步的。只要把“同步注册”和“微任务排队”这两个时间点理清题目就没有任何意外。4. 浏览器渲染、requestAnimationFrame与事件循环4.1 微任务清空先于渲染这是浏览器的硬规则浏览器的事件循环比一个单纯的 JS 任务调度器更复杂因为它在处理完 JS 之后还要负责渲染。标准机制里一个宏任务执行完毕、微任务队列清空之后浏览器会在取下一个宏任务之前判断是否需要进行一次渲染。微任务队列被安排在渲染之前清空是有意设计。这样做的最大收益是当你通过Promise或者queueMicrotask去修改 DOM 时这些修改能够赶在渲染发生前完成浏览器统一布局、统一绘制不会产生中间态。反过来如果把 DOM 修改放到setTimeout里宏任务的触发时机经常在渲染之后就有可能造成两次渲染甚至出现闪烁动画。这也是为什么很多现代动画库会用微任务批量收集 DOM 变更而不是每次回调都立刻同步操作 DOM。事件循环的“硬规则”是微任务队列不空渲染步骤就可能被一直推迟。4.2 requestAnimationFrame在哪个位置触发除了微任务和宏任务还有一个常常被放在一起比较的机制requestAnimationFrame简称rAF。它的回调是在渲染之前执行的浏览器会根据屏幕刷新率决定何时触发而不是像setTimeout那样按固定毫秒值触发。rAF与事件循环的位置关系可以用一个简化的序列来描述宏任务执行完毕。清空微任务队列。执行requestAnimationFrame回调。进行样式计算、布局、绘制。回到宏任务队列取下一个任务。这个顺序在大部分普通帧里是成立的。它意味着rAF回调先于渲染但晚于微任务清空。如果你希望让动画代码在渲染前拿到最新的 DOM 状态rAF是比setTimeout更可靠的选择如果你希望代码在渲染前执行但对渲染时机没那么敏感直接使用微任务也是可以的。要注意的是在微任务阶段不断产生新的微任务会让事件循环一直停留在“清空微任务”这一步导致rAF和渲染永远等不到执行的机会。这是很多隐藏卡顿问题的根源。4.3 用DOM操作观察渲染时机想实际观察微任务与渲染的先后关系可以用一个很简单的实验。假设页面上有一个box元素我们分别在微任务和宏任务里修改它的样式const box document.getElementById(box); console.log(mark: 同步代码); Promise.resolve().then(() { box.style.transform translateX(100px); console.log(mark: 微任务里修改样式); }); setTimeout(() { box.style.backgroundColor red; console.log(mark: 宏任务里修改样式); }, 0);在大多数浏览器里用 Performance 面板录制这段代码能看到第一次渲染发生在微任务修改样式之后、setTimeout回调之前。也就是说translateX的变更直接体现在第一次绘制里而背景色的变更往往出现在下一次绘制中。如果你看到的动画总有一帧“跳过去”或者闪烁可以检查一下是不是有一个宏任务在渲染后的时机里修改了 DOM。这种“改在错误时间点”的问题比代码逻辑本身的 Bug 更隐蔽。5. Node.js的事件循环libuv阶段、nextTick与setImmediate5.1 Node的宏任务分布在各阶段Node.js 的事件循环跟浏览器不完全一样底层由 libuv 实现。它并不是简单的一个宏任务队列加一个微任务队列而是把大量异步操作划分成多个阶段循环执行。最常被提到的几个阶段如下阶段主要作用timers执行setTimeout、setInterval到期的回调pending callbacks执行部分系统操作的回调如 TCP 错误idle / preparelibuv 内部使用poll获取 I/O 事件执行文件读取、网络请求等回调check执行setImmediate的回调close callbacks处理socket等对象关闭时的回调浏览器里的宏任务在 Node 中分散到了不同阶段里的回调它们共享“宏任务”这个性质。Promise微任务和process.nextTick则不属于这些阶段它们会在 V8 执行完一段 JS 之后、事件循环进入下一个阶段之前被处理。5.2 process.nextTick比Promise微任务更靠前Node 里有一个经常被拿来跟微任务比较的 APIprocess.nextTick。虽然名字里带 next但它并不是常规意义的微任务队列而是单独维护的。重要的是它的优先级高于 Promise 微任务而且会在当前操作结束后、事件循环切换到下一阶段之前把整个nextTick队列尽量执行完。测试这段代码process.nextTick(() console.log(nextTick)); Promise.resolve().then(() console.log(promise)); setTimeout(() console.log(timeout), 0);输出顺序永远是nextTick promise timeoutnextTick排在promise前面这个结果在 Node 中很稳定。因为它比 Promise 微任务拥有更高优先级两者的队列都是独立维护的。如果你在nextTick回调里再次调用process.nextTick新的回调仍然会排在 Promise 微任务之前执行这就带来一个非常危险的后果无限调用process.nextTick同样会让 Promise 微任务和后续阶段饿死。5.3 setTimeout(0)与setImmediate的“不稳定”顺序Node 中一个经典的顺序题是在主模块里同时注册setTimeout(0)和setImmediatesetTimeout(() console.log(timeout), 0); setImmediate(() console.log(immediate));多运行几次你可能会发现顺序时而是timeout在前时而是immediate在前。原因在于setTimeout(0)在 libuv 里实际上被视为大约 1 毫秒的延迟而主模块进入事件循环时的系统时间可能刚好处于一个微妙临界点如果进入 timers 阶段时计时器还没有到期事件循环就会先走到 poll再走到 check 执行setImmediate如果计时器到期了就先执行timeout。但换一个场景如果把同样的两行代码放进一个 I/O 回调里结果通常就稳定了const fs require(fs); fs.readFile(__filename, () { setTimeout(() console.log(timeout), 0); setImmediate(() console.log(immediate)); });绝大多数情况下会先输出immediate再输出timeout。这是因为 I/O 回调本身处于 poll 阶段执行完之后事件循环会先进入紧接着的 check 阶段执行setImmediate而计时器要等到下一轮事件循环的 timers 阶段才会被检查。这个差异不理解 Node 的阶段模型会很难解释。6. 实战排错与自检写出可预测的异步代码6.1 综合顺序题手动推演一遍理论讲了这么多最后来一道综合题把Promise、setTimeout、queueMicrotask混在一起你可以自己先推出答案再看下面推演console.log(sync-1); setTimeout(() { console.log(timeout-1); }, 0); Promise.resolve() .then(() { console.log(micro-1); setTimeout(() { console.log(timeout-2); }, 0); }) .then(() { console.log(micro-2); }); queueMicrotask(() { console.log(micro-3); }); console.log(sync-2);输出顺序sync-1 sync-2 micro-1 micro-2 micro-3 timeout-1 timeout-2推演过程同步阶段输出sync-1、sync-2同时注册了timeout-1宏任务、第一个.then微任务、第二个.then微任务注意这里并不是注册了才执行而是在第一个.then执行后产生以及micro-3微任务。微任务队列按注册顺序为micro-1、micro-2、micro-3。执行微任务时micro-1中输出micro-1并向宏任务队列追加timeout-2micro-2和micro-3继续输出。微任务队列清空后宏任务队列里现有timeout-1和timeout-2依次输出。这道题的关键是micro-2并不因为写在第二个.then位置上就等待额外一轮微任务清空它会被登在第一轮已经返回的 Promise 链上作为新微任务继续执行直到队列为空。6.2 微任务饥饿setTimeout为什么被活活饿死网上很多文章提醒过“不要在微任务里递归无限调用微任务”但很少展示真实后果。这段代码就是一套经典的饥饿演示function infiniteMicrotask() { Promise.resolve().then(infiniteMicrotask); } infiniteMicrotask(); setTimeout(() { console.log(timeout never runs); }, 0); console.log(sync code runs);运行之后你能看到sync code runs输出但那个setTimeout回调可能永远不执行。因为事件循环每次试图清空微任务队列时队列里都已经塞进了由上一轮then产生的新微任务微任务队列永远清不空宏任务队列就永远轮不到。这种现象叫做微任务饥饿。解决方法是不要用微任务做递归而是用宏任务级别的调度让出执行机会。浏览器里可以用setTimeout或MessageChannelNode 里可以用setImmediate。核心思路是某些任务本来就应该排到宏任务队列里强行用微任务只会在事件循环内部形成死循环。6.3 用打点和Performance面板验证时序我在排查不确定顺序的异步代码时不会只靠脑内推演而是直接在代码里打标记。最简单的方法是定义一个小工具function mark(name) { console.log(${name} ${performance.now()}); }然后在每个任务、每个微任务回调的开头调用mark。通过时间戳可以立刻看到哪些回调属于同一批微任务清空操作哪些回调被分到了后续宏任务里。如果需要可视化分析可以用浏览器开发者工具的 Performance 面板录制执行过程找到标有Task的长条微任务区域通常会出现在 Task 内部或 Task 结束后以浅色标记呈现。在 Node 中也可以用node --trace-timers 脚本.js来观察定时器回调的触发时机虽然输出更底层一些但对定位确实有帮助。6.4 工程上的三点建议第一能用async/await表达的串行逻辑尽量用async/await它比裸写 Promise 链更容易看清“sync 部分”和“微任务部分”的边界。第二避免在微任务中递归派发新微任务任何递归都要考虑用宏任务调度重置事件循环机会。第三对依赖执行顺序的代码不要依赖“我觉得 Promise 肯定先跑”这种直觉而是在临界位置打好标记用一次真实运行代替口头推测。我在实际项目里看到的不少诡异 Bug最后都落到同一个原因有人把一段需要等待渲染的逻辑放进了微任务被人为推到了渲染前或者把本应作为宏任务调度的递归写成了微任务递归直接把事件循环卡到喘不过气。事件循环并不难懂难的是每次写异步代码时都保持对队列边界的敏感。说实话事件循环之所以值得花时间深挖因为它就是 JavaScript 异步世界的“物理定律”。只要对宏任务和微任务的执行机制有了准确的画面感很多顺序问题不用试也能一眼看穿。在你下次被某个诡异输出困住之前试着把执行过程画成“宏任务 → 微任务清空 → 下一个宏任务”的循环图八成问题当场就解开了。