资讯动态

JavaScript事件循环:宏任务与微任务执行顺序详解

发布时间:2026/10/18 12:07:28 来源:尧图企业网站定制
“明明只是调了个接口为什么页面上有两个按钮的样式同时对不上了”这是我那个在做了五年前端的朋友某天下午发来的语音消息带着一点疲惫和很多困惑。他接手的老项目里有段逻辑在请求回调里用循环更新了几个状态结果渲染的节奏和他预期的完全不一样。最后定位到问题绕不开一个核心概念JavaScript的单线程执行机制以及它底下那张负责调度一切的“时间表”——事件循环Event Loop更确切地说是宏任务Macro Task和微任务Micro Task的排队规则。如果你也有过类似的经历比如连续修改一个变量的值却发现视图更新的表现跟自己脑补的完全对不上或者 setTimeout 设定的延时总是不准又或者 Promise 的执行顺序莫名其妙那你大概率就是没理顺这一整套机制。这篇文章会把这些概念彻底拆开从最底层的执行原理讲到最上层的代码实践把回调函数、Promise、async/await 和事件循环之间那层关系用大白话和代码示例掰扯明白带你绕开我在实际开发里踩过无数次的坑。1. 为什么单线程还能“同时”干多件事从执行栈到任务队列1.1 单线程到底是什么意思很多人刚接触 JavaScript 时都会被“单线程”这个概念唬住以为它一次只能做一件事那网页上的动画、滚动、点击事件怎么可能同时响应这里的“单线程”指的是 JavaScript 引擎本身只有一个主线程用于执行代码但它并不是像老式收音机只能播一个台那样死板而是配合一套非常精妙的调度机制让你产生“同时干很多事”的错觉。主线程上有一个东西叫执行栈Call Stack你可以把它想象成一个“干活清单”存放着当前正在执行和等待执行的函数调用。当一个函数被调用时它就被压入栈顶当这个函数执行完它就被弹出栈底。整个过程遵循后进先出LIFO的原则。比如function foo() { console.log(foo); bar(); } function bar() { console.log(bar); } foo();执行栈的运行过程是全局上下文main入栈。调用foo()foo入栈。foo内部打印 foo然后调用barbar入栈。bar内部打印 bar执行完出栈。foo执行完出栈。全局上下文出栈代码执行完毕。所以单线程指的是这个“干活清单”上永远只有一个活在被处理不存在两个函数同时在栈顶执行。但浏览器里还有很多其他东西比如网络请求XHR/Fetch、定时器setTimeout、DOM 事件、渲染引擎等这些并不是 JavaScript 主线程亲自去做的而是通过浏览器提供的 Web API 去跑——它们运行在各自的线程里完成后才把结果“送回”主线程。1.2 任务排队的核心任务队列Task Queue关键点来了当主线程上的代码在执行栈中跑得正欢的时候那些从 Web API 返回的结果、定时器到期的回调、事件触发的处理函数都不能直接插队进来它们得排队。这个“排队”的地方就是任务队列Task Queue。队列里存放的不是数据本身而是回调函数。它们遵循先进先出FIFO的顺序等着主线程空闲下来之后由事件循环逐一把它们拉到执行栈中执行。举个例子console.log(start); setTimeout(() { console.log(timeout); }, 0); console.log(end);这段代码的输出顺序是start、end、timeout而不是start、timeout、end。原因很简单setTimeout被调用时它把回调函数交给了浏览器的定时器线程设置延时 0 毫秒。主线程继续往下走执行console.log(end)。主线程空闲后事件循环才把定时器线程已经准备好的回调函数推到执行栈中执行。所以哪怕延时是 0它也绝对不可能在end之前执行因为你不可能在主线程还在干活的时候插队。1.3 事件循环Event Loop就是那个调度员事件循环Event Loop是连接执行栈和任务队列的桥梁它做的事用一个死循环来概括就是检查执行栈是否为空。如果栈为空就从任务队列中取出第一个回调放入执行栈执行。重复第一步。它不停地在“看栈是否空、队列是否有东西”之间循环直到没有任务可执行为止。这个模型很好地解释了为什么 GUI 渲染进程不会因为长时间运行 JS 而彻底卡死——虽然大规模的同步计算确实会阻塞渲染但任务队列里的回调只要栈一空就会被及时调度。2. 宏任务与微任务的真正区别谁有更高的优先插队权2.1 从一次页面渲染说起既然已经有任务队列了为什么还需要再分宏任务和微任务直接全部先进先出不就行了这就得说到一个实际痛点异步回调的执行时机和 DOM 渲染的优先级。假设你有一段代码在请求回来之后更新部分 DOM 节点。如果你把更新 DOM 的操作放在宏任务里那么事件循环在处理这个宏任务时可能会在你预期的渲染前插入其他的宏任务导致多个宏任务之间反复触发渲染性能会有不必要的损耗但如果用微任务情况就优雅得多因为微任务执行时机在“当前宏任务结束后、下一个宏任务开始前”而且这个时机是在浏览器要渲染页面之前能保证你一次性把所有 DOM 更新在同一个渲染周期内完成。2.2 宏任务包含哪些宏任务主要包含setTimeout、setIntervalsetImmediateNode.js 环境I/O操作文件读取、网络请求回调等UI 渲染严格来说渲染本身是浏览器行为但宏任务执行完成后会检查是否有渲染需求事件回调比如点击事件的 handler宏任务之间是平等的它们都排在任务队列里谁先到谁先执行没有优先级的说法。2.3 微任务包含哪些微任务的种类相对较少但个个都关键Promise.then、Promise.catch、Promise.finally中的回调MutationObserver回调观察 DOM 树变化的 APIqueueMicrotask()方法显式加入的任务在 Node.js 环境下的process.nextTick微任务的典型特征是短小、快速、高优。它们被放在一个叫微任务队列Microtask Queue的地方这个队列和宏任务队列之间有一条铁律每个宏任务执行完之后事件循环必须把当前微任务队列里的所有微任务全部清空然后才去处理下一个宏任务或者进行渲染。这条铁律极其重要。如果你在微任务里又往队列里加入新的微任务那么这个新微任务会在同一个宏任务周期里被执行这个周期会一直持续到微任务队列为空外部的宏任务才会被处理。2.4 一道经典题目带你理解优先级很多面试题会考下面的输出顺序console.log(script start); setTimeout(function () { console.log(setTimeout); }, 0); new Promise((resolve) { console.log(promise executor); resolve(); }).then(function () { console.log(promise then); }); console.log(script end);完整输出是script start promise executor script end promise then setTimeout流程拆解执行全局代码打印script start。遇到setTimeout把回调放入宏任务队列。执行new Promise的 executor 函数打印promise executor并调用resolve()把then回调放入微任务队列。继续执行打印script end。全局代码执行完毕当前这个宏任务结束。事件循环清空微任务队列打印promise then。微任务队列为空事件循环取出下一个宏任务setTimeout的回调打印setTimeout。如果你这就觉得理解了那我再抛一个更阴间的在then里面再加一个Promise.resolve()或者再塞一层queueMicrotask输出顺序又会如何要理解那部分你就得彻底明白微任务队列的“全部清空”策略——当前微任务执行时新建的微任务不会被排到下一个宏任务周期而是直接排到当前周期微任务队列的尾部。console.log(start); setTimeout(() console.log(t1), 0); Promise.resolve() .then(() { console.log(p1); Promise.resolve().then(() console.log(p2)); }) .then(() console.log(p3)); console.log(end);输出顺序是start end p1 p2 p3 t1注意p2的执行并不是等下一个宏任务而是在p1执行完、p3入队之后事件循环继续把p2从微任务队列里拿出来执行。微任务队列对整个宏任务周期来说是独占性的执行到天荒地老也得清空它。3. 事件循环的完整模型从浏览器到 Node.js 的一点差异3.1 浏览器里的一个完整循环周期要真正理解宏任务和微任务的执行方式脑子里得有一张这样的流程图感执行一个宏任务从任务队列头部取一个。执行期间如果产生了微任务把它们全挂到微任务队列。宏任务执行完毕执行栈清空。开始清空微任务队列一个接一个执行如果微任务执行过程中又产生新的微任务继续加到队列尾部一直到整个微任务队列清空。微任务队列清空后事件循环会检查是否需要渲染页面如果需要就执行渲染。回到第 1 步取下一个宏任务。这套模型能解释为什么你把大量的耗时处理放在微任务里会导致页面卡死因为微任务会在当前宏任务结束前被无限制地执行浏览器根本没机会渲染用户看到的页面就僵住了。3.2async/await的“伪装”很多人初学async/await时把它们理解成“同步等待”。这句话在直觉上没问题但机制上是不准确的。await并不是在阻塞主线程它只是把后面的代码放到了微任务队列里。async function test() { console.log(hello); await new Promise((resolve) { setTimeout(() { console.log(after 1s); resolve(); }, 1000); }); console.log(world); } test(); console.log(immediate);输出顺序hello immediate after 1s world原因不复杂test函数执行到await时整个函数被“挂起”了后面那部分console.log(world)被包装成一个微任务放入微任务队列。主线程继续执行全局代码打印immediate。之后 1 秒的宏任务触发resolve执行微任务队列里的world才得以执行。再比如一个容易出错的例子async function foo() { console.log(1); await bar(); console.log(3); } function bar() { console.log(2); return Promise.resolve(); } foo(); console.log(4);输出顺序是1 2 4 3这里要特别注意bar函数体内的console.log(2)是同步执行的await只暂停了foo中await之后的代码并不会暂停被 await 的那个函数的同步部分。理解这一点能省去很多排查 bug 的时间。3.3 Node.js 环境下的事件循环差异Node.js 的事件循环和浏览器略有不同底层的libuv库把事件循环分成了多个阶段Phase包括timers处理setTimeout和setIntervalpending callbacks处理某些系统错误回调idle, prepare内部使用poll处理 I/O 事件像文件读取、网络请求check处理setImmediateclose callbacks处理一些关闭事件在 Node.js 里process.nextTick比较特殊它的优先级比Promise.then还要高会在每个阶段切换时优先把nextTick队列清空。所以在 Node.js 环境中如果混用process.nextTick和Promise输出顺序可能是process.nextTick(() console.log(nextTick 1)); Promise.resolve().then(() console.log(promise 1)); setImmediate(() console.log(setImmediate)); setTimeout(() console.log(setTimeout), 0);但这里有个陷阱setTimeout和setImmediate的顺序在 Node.js 环境里并不固定取决于进入事件循环时的时间点是否已经达到了 timers 阶段的要求。如果在启动时执行这两个调用通常setTimeout先但如果在 I/O 循环内部去调用setImmediate往往先。这个坑我当年排查了半天才明白不是你代码写错了是环境阶段不同导致的。4. 大坑与小技巧我踩过的那些执行顺序相关的坑4.1 坑一setTimeout的延时是“最少延时”setTimeout(fn, 0)并不是说 0 毫秒后一定执行而是“至少 0 毫秒”。实际情况取决于主线程有多忙、任务队列里排了多少任务、浏览器当前是否处于后台标签页有些浏览器还会把后台标签页的定时器强制降频甚至暂停。我遇到过有些老版本的 IE 浏览器对setTimeout的最小间隔限制是 10 毫秒甚至更夸张。所以你在跨浏览器开发时千万别把setTimeout当精确计时器用。要知道它只是一个“推后执行”的手段不是一个高精度定时工具。如果你需要动画帧率就用requestAnimationFrame如果你需要精确的时间计算就去读performance.now()别指望setTimeout的实际触发时间和设定时间完全一致。4.2 坑二微任务里“递归”创建微任务会把页面搞崩遇到过一个实际场景某个老系统里有一段接口轮询逻辑原本是用setTimeout实现的结果某次重构有人把它换成了Promise.resolve().then()的递归调用目的是“让代码看起来更异步、更友好”。结果代码一上线页面时不时白屏卡死。原因前面已经说透了微任务队列会一直清空到什么都没有才停如果你在微任务里不断添加新微任务浏览器永远等不到渲染的机会表现就是页面无响应。而用setTimeout递归就不一样每个宏任务之间有渲染的机会页面还能闪一闪。这是一个典型“微任务能执行但滥用会致命”的案例。4.3 坑三事件回调里的同步阻塞假设你给某个容器绑定了点击事件回调函数里有一个非常耗时的同步计算那段计算会在渲染事件完成前霸占执行栈。用户看到的页面就是点了没反应、卡顿不能交互。因为事件回调必须先作为宏任务被执行完微任务清空渲染才有机会发生。解决办法是把耗时任务拆解成独立的宏任务比如每次只处理一部分数据或者拆给 Web Worker而不是把所有计算一锅炖。4.4 调试技巧用断点和日志的组合判断执行顺序在开发环境里我一般会写一个小工具函数用来打印当前执行栈和队列状态的“探针”。但坦白讲最直观的方式还是console.log加断点的组合在可疑位置打断点然后看浏览器 DevTools 的 Call Stack 面板能清晰看到执行栈的入栈和出栈过程同时配合日志输出基本可以判断代码走到了哪一步、下一步队列里有哪些任务在等待。打开 Chrome DevTools 的Sources面板你还能看到 React DevTools 的“Profiler”但如果是普通的 JavaScript 项目直接在代码里打点记录Date.now()或performance.now()更直观对比时间戳能判断异步回调是哪个阶段触发的。4.5 经验技巧用宏任务给动画留出渲染空间如果你正在做一个复杂的动画节点特别多建议把每一帧的计算放在requestAnimationFrame里把某些状态更新用微任务做然后在下一次requestAnimationFrame里完成实际的 DOM 更新。简单说一句话凡是和视图渲染强相关的操作尽量和微任务分开凡是和视图变化没有直接关系的异步逻辑可以用微任务提升响应速度。这两者之间不是谁一定好而是要看你的场景需求。5. 一些微任务与宏任务的实际应用场景解析5.1 使用微任务优化“批量更新”假设你有一个复杂的组件需要监听一些外部数据的变化数据源可能在一次调用中连续改变了多次但你希望只在所有数据变化完成后执行一次重渲染。你可以设计一个“合并”机制第一次修改数据时用queueMicrotask或者Promise.resolve().then()注册一个微任务在微任务里执行重渲染。如果同一轮事件循环里又有新数据变更由于此时微任务还没执行完你可以通过一个标志位知道已经注册过了不用再次注册。这样就能保证 DOM 渲染只在微任务队列清空前执行一次而不会因为多次状态更新导致页面闪烁。类似的思想在 Vue 和 React 的调度器里都有体现只是它们做了更复杂的优先级调度。5.2 使用宏任务实现任务分片如果一个任务非常大比如要一次性处理十万条数据直接同步执行会卡死页面。你可以把它切分成多块每一块放到setTimeout(fn, 0)里执行每执行完一块就让浏览器有机会渲染和响应。const dataList new Array(100000).fill(0); function processBatch(start, end, chunkSize) { for (let i start; i end; i) { // 处理数据 dataList[i] i * 2; } if (end dataList.length) { setTimeout(() processBatch(end, end chunkSize, chunkSize), 0); } else { console.log(处理完毕); } } processBatch(0, 1000, 1000);这里每处理完 1000 条就让出主线程让浏览器有机会刷新界面。虽然总耗时可能比一次性处理更长但用户的体验要好得多——页面不会死掉。5.3 防止“微任务饥饿”的排查思路有时你写了一段递归的 Promise 逻辑值不会无限循环但递归层数深到一定程度页面仍然可能有明显的卡顿。我的排查思路一般是三步打开 DevTools 的 Performance 面板录制一段。查看 Frame 时间轴看吞吐量是否连续黄色阻塞块。在代码里搜索.then(和queueMicrotask以及Promise.resolve()的递归调用确认是否存在微任务递归。如果是就改成setTimeout版的递归或者引入节流/批处理机制把一小部分任务延后到下一个宏任务。5.4 定时器嵌套与时间误差的处理当你有多个setTimeout嵌套使用时每一层的实际触发时间都会比预期偏大因为每个宏任务执行之间还夹着微任务清空和渲染。如果你写一个倒计时功能直接依赖setTimeout的固定间隔去计算剩余时间会有较大的误差。正确的做法是只用一个setTimeout固定间隔去刷新界面每次刷新时用Date.now()或performance.now()去计算真实剩余时间而不是简单地“每次减一”。这样即使事件循环被某些任务耽误了界面上的倒计时也能较精确地同步实际时间。6. 工具选型与代码规范让执行顺序可预测6.1 什么时候用queueMicrotask新项目里我一般首选queueMicrotask因为语义更清晰明确表示“这是一个微任务”比借助Promise.resolve().then()这种绕弯子写法可读性好很多。而且queueMicrotask在现代浏览器和 Node.js 12 都已经支持兼容性完全够用。用法示例queueMicrotask(() { console.log(microtask!); });它的优先级和Promise.then一样队列按 FIFO 排列。所以你混用的时候还是得小心顺序不能以为queueMicrotask一定比所有 Promise 回调都早它只在同一队列内排队。6.2 模块化设计异步流程的规范在一套多人协作的代码库里异步逻辑的可读性往往比“跑得快”更重要。我习惯在团队里立几条规范任何异步操作进入 async 函数后先await一个setTimeout或读一个 IO尽量不要在微任务里做重型运算。所有链式.then分支尽量合并到同一层避免深层嵌套造成执行顺序的不确定性。用async/await替代裸 Promise 链因为等待管理可读性更好代码顺序更接近同步写法。对于需要执行后返回的微任务操作建议命名清晰别写那种.then(() { return void 0; })之类的无意义代码。如果需要控制并发别自己手写计数器用成熟方案或Promise.all/Promise.allSettled它们的执行顺序也是基于微任务队列的。6.3 自测小工具为了确保重构不改变执行顺序我常写一个小型脚本function logOrder(tag) { console.log(${tag} ${performance.now()}); } setTimeout(() logOrder(timeout-0), 0); setTimeout(() logOrder(timeout-100), 100); Promise.resolve().then(() logOrder(promise-1)); Promise.resolve().then(() logOrder(promise-2)); queueMicrotask(() logOrder(microtask)); logOrder(sync);跑一次你能直观看到同步先执行、微任务紧随其后、宏任务最后而这中间的时间戳差异正是事件循环各阶段切换的开销。这个脚本我几乎在每个需要排查异步问题的项目里都会临时加一段方便对比不同浏览器环境下的差异。7. 常见面试问题深度解读7.1 并发上下文中的执行顺序考题经常这样出setTimeout(() console.log(A), 0); Promise.resolve().then(() { console.log(B); setTimeout(() console.log(C), 0); });输出顺序是B、A、C。流程是全局同步代码执行完 → 清空微任务队列打印B注册C的宏任务到队列尾部→ 取宏任务队列头部此时队列里的宏任务有A和C两个A 在头部 → 打印A→ 下一个循环取到C。这里很容易被绕晕但只要记住微任务注册的新宏任务是排在当前宏任务队列尾部的就不会错。7.2 微任务中创建的微任务是否会导致“宏任务饥饿”理论上如果微任务里不断添加新微任务宏任务队列永远不会被处理这种现象叫“微任务饥饿”Microtask Starvation。但在实际渲染引擎中微任务队列的执行有一个“上限保护”吗这要看浏览器实现。现代浏览器普遍没有强制的微任务数量上限所以写成死循环就是死循环页面必卡。所以在面试中被问到“微任务会不会饿死宏任务”时标准回答是从规范上来说事件循环会一直清空微任务队列直到为空所以理论上微任务确实可以无限延迟宏任务执行造成饥饿从工程实践上说应该尽量避免在微任务中直接或间接产生无限递归否则页面将失去响应。7.3async/await与 Promise 的执行顺序对照async函数内的await之后的代码本质上是Promise.then中的代码。所以你可以把async function test() { await something(); console.log(after); }等价理解为function test() { return something().then(() { console.log(after); }); }注意something()的调用时机不同。前者是在async函数体内同步调用something()函数本身后者也是同步调用所以两者几乎等价但细节上如果something()返回的不是 Promiseawait会先把它包装成Promise.resolve()而.then也会做同样的包装所以行为一致。8. 实践建议与最后一些心得8.1 给你的项目做一次“事件循环体检”我每隔一段时间就会给长期维护的项目做一次异步逻辑“体检”打开 Performance 面板录制用户高频操作场景观察哪些任务的执行远远超过了预期帧预算再统计代码里注册的所有setTimeout和Promise.then看看调用频率是否与性能瓶颈相关。一旦发现某个操作序列频繁触发大量微任务基本能锁定到某段代码逻辑设计有误。最常见的就两种一种是在循环里直接await某个异步函数导致每个循环项都往微任务队列里塞一个回调另一种是状态管理库的订阅更新触发连环.then。实际上循环里直接await不一定是个好的做法它会让每个元素变成串行执行。如果各元素之间没有依赖应该用Promise.all并行提交执行总耗时能降一个量级。8.2 保持代码的“同步假象”但代价要清晰async/await让我们可以像写同步代码一样写异步逻辑这是它最大的魅力但也容易让人忽略背后的调度成本。每次await都意味着一次微任务调度虽然单个调度开销很小微秒级但在循环里成百上千次使用累计开销依然不可忽略。如果性能敏感建议把循环内可以静态计算的部分提到循环外减少不必要的重复等待。8.3 最后一个个人体会我在刚接触这个概念时也天真地以为只要知道了宏任务和微任务的名字就能解决所有异步顺序问题。但实际上真正让代码变稳的是坚持把执行顺序的推断建立在“事件循环周期”上而不是“时间间隔”上。时间是一个作弊的特权但事件循环没有特权它只按规则来——宏任务排队、微任务插队、渲染等待。每次遇到异步问题先画一张极简的执行周期图标注宏任务、微任务、渲染三个阶段再把代码对照上去基本都能找出问题。希望这篇文章不只是让你看懂概念更能帮你养成用事件循环思维去分析问题的习惯。下一次再碰到让人抓狂的异步顺序问题时你至少知道该去哪里看它的“排队号码”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑