ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript事件循环机制详解:宏任务、微任务与异步执行顺序

JavaScript事件循环机制详解:宏任务、微任务与异步执行顺序 1. JavaScript 为什么需要事件循环1.1 单线程的宿命与妥协先回答一个最基础的问题JavaScript 为什么要搞出事件循环这么一套机制核心原因是这门语言从出生起就是单线程的。你想想如果 JS 真能同时开多个线程干活两个线程同时操作页面上的同一个 DOM 节点一个把它改成红色一个把它改成蓝色最后到底听谁的这种冲突要么靠加锁解决要么靠事务合并复杂度能直接拉满。浏览器要的是交互实时性所以干脆让 JS 只能在一个线程上跑所有耗时的任务都用回调、事件、定时器的形式扔出去等结果回来了再拿回主线程继续执行。但问题来了扔出去的任务总得有人维护秩序吧总不能让回调一到达就强行打断当前正在执行的代码。于是浏览器和 Node.js 就实现了一套调度机制这就是事件循环。我习惯把它想象成一个只有一个收银员的便利店顾客就是一个个任务收银员就是主线程只能一个一个结账。事件循环就是那套排队规则决定什么时候放新顾客进来、什么时候允许特定顾客插队、什么时候所有顾客都结完账可以歇一会儿。理解了这套规则JavaScript 的异步行为就不再是背结论而是能自己推演出来的逻辑链。很多人会误以为“单线程”意味着不能处理并发这其实混淆了底层并发模型。单线程指的是 JavaScript 代码执行层面只有一个主线程但浏览器和 Node.js 在底层可没闲着。浏览器的网络请求、定时器、事件监听都是由宿主环境的其他线程负责等事情有了结果再把回调任务丢进事件循环的队列。所以说准确一点JavaScript 是单线程执行代码宿主环境是多线程处理杂事事件循环是两个世界之间的传送带。1.2 事件循环是调度员不是执行者很多新手把“事件循环”理解成 JS 里的 for、while 那种循环语句这是个很大的误解。它不是写在业务代码里的语法而是宿主环境底层维护的一个无限循环调度器。这个调度器自己不执行你的函数它的职责是不断重复几件事检查调用栈是不是空的、看看宏任务队列里有没有活、看看微任务队列里有没有活、把取到的任务交到调用栈上执行。理解这个角色很重要。调度员和执行者是分离的拿收银员来举例收银员本身不会去仓库搬货搬货是配货员的事收银员只负责手里那张“当前任务纸条”。事件循环就是那个决定“搬哪个货过来”的调度员而调用栈才是收银员手上真正在执行的那张纸条。你要是把两者混为一谈很多执行顺序问题就理不清楚了。为什么事件循环永远不会停你可以反过来想如果主线程把同步代码全部跑完就睡觉了后面的用户点击、网络响应、定时器回调谁来处理所以事件循环本质上是一个“睡醒了就看看有没有新活”的机制每一次循环迭代都是一次检查。浏览器里为什么叫 event loop 而不是 event poller就是因为它这种循环往复的特性。掌握了这一层思想再看任何异步输出顺序题思路都会顺很多。2. 三个核心部件调用栈、宏任务队列、微任务队列2.1 调用栈一条道走到黑调用栈是所有理解的基础它是 LIFO 结构后进先出。JS 执行同步代码的时候遇到函数调用就往栈里压入一个执行帧函数 return 的时候再弹出。最简单不过但很多人忽略的是栈上当前正在执行的东西没有结束之前事件循环是没办法把新任务塞进来的这个地盘是排他的。你可以把调用栈理解为“当前正在做的事”而队列里排队的都是“还没轮到的备选任务”。栈溢出是怎么回事当你写了一个递归函数没有退出条件比如 function explode() { explode() }每调用一次就往栈里塞一个帧栈空间很快被填满浏览器直接抛 RangeError: Maximum call stack size exceeded。我在实际项目里就遇到过有人用递归去解析深度嵌套的 JSON 结构递归层数太深又没设计好退出条件最终导致栈爆掉。解决办法是改成迭代加显式数据结构或者用队列逐个处理。调用栈一满事件循环也会停下因为调用栈腾不出空间来接受新任务什么定时器、Promise 都得靠边站。这里要顺带纠正一个常见误区有些人觉得“调用栈空了”就是所有代码执行完了。其实不是。调用栈空只代表当前正在执行的任务结束了但宏任务队列和微任务队列里可能还排着一堆东西等着事件循环来取。事件循环插入的时机恰恰就是每一次调用栈从“非空”变“空”的那个瞬间。所以理解异步必须同时盯着调用栈和两个队列单纯看其中一个都会漏掉关键信息。2.2 宏任务队列按部就班的待办清单宏任务在浏览器标准里叫 task我更喜欢把它理解成“一大筐待办事项”。setTimeout、setInterval、用户点击事件、键盘输入、网络请求完成回调包括浏览器解析 script 标签时的主代码执行都会产生宏任务。这些任务按照产生顺序排在宏任务队列里事件循环每次只从队头取一个出来执行。规矩很死不是一批一批取一次就一个。为什么一次只取一个这是为了防止某个回调太耗时无限霸占主线程。一个宏任务执行完事件循环会强制停下来检查微任务队列和渲染需求而不是把这个筐里的任务全部倒完。你类比成快递员一栋楼只送一个快递送完就得看一眼有没有别的急事再去送下一栋楼。这个特性在后面讲渲染时机的时候会非常关键。值得注意的是主线程一开始执行的整个 script 代码本身也算第一个宏任务。也就是说你在页面里写的那一大段同步逻辑是在一个宏任务里整体执行的。它会把同步部分从头到尾跑完如果中途注册了定时器或 Promise这些新任务会被塞进对应队列但不会立刻执行必须等这个宏任务彻底结束才会轮到它们。2.3 微任务队列插队也要讲规矩微任务这个概念真正火起来是因为 Promise 大规模普及它的队列优先级比宏任务高。Promise.then 注册的回调、queueMicrotask 注册的回调、MutationObserver 触发的回调都会进入微任务队列。规则就一条在一个宏任务执行结束之后事件循环不会立刻去取下一个宏任务而是先把微任务队列从头到尾全部清空清空之后才考虑下一个宏任务。注意“全部清空”的力度。微任务里如果又注册了新的微任务比如在 .then() 里又 return 一个 Promise 继续链式调用这些新产生的微任务会在同一轮里面继续执行直到队列彻底空了为止。为什么设计成这样因为微任务是最贴近当前任务的后置逻辑Promise 链的语义要求“前一个 then 的结果能立即被下一个 then 处理”如果每次只处理一个微任务又跑去看宏任务那 Promise 链就会变得支离破碎写起来非常难受。微任务的“插队”不是说它可以抢占正在执行的同步代码而是说它在当前宏任务结束后的第一时间插入。这个区别非常重要。这里看一个经典输出顺序setTimeout(() console.log(TIMEOUT)); Promise.resolve().then(() console.log(PROMISE)); console.log(SCRIPT END); // 输出结果 // SCRIPT END // PROMISE // TIMEOUT原因是 console.log(SCRIPT END) 属于当前宏任务的同步部分必须先执行Promise.resolve().then 注册回调进入微任务队列setTimeout 注册回调进入宏任务队列。当前宏任务结束后先清空微任务队列所以 PROMISE 先打印下一轮事件循环才轮到 TIMEOUT。3. 浏览器事件循环的完整运转流程3.1 一次循环里到底先干什么后干什么现在把全部部件串起来。我看一段代码的输出顺序会在脑子里走这么一条路线当前宏任务的同步代码从头执行到尾遇到函数调用就压入调用栈执行过程中碰到定时器、事件监听、网络回调就放进宏任务队列碰到 Promise 链、queueMicrotask、MutationObserver就放进微任务队列当前宏任务执行完调用栈变空先把微任务队列清空队列里每取一个微任务就执行执行中如果又注册新微任务就继续往队尾追加直到彻底清空浏览器如果检测到有渲染需求在这轮执行渲染最后从宏任务队列头部取下一个宏任务重复以上过程。这里面最值得深挖的是渲染时机。浏览器并不是每轮循环都强制渲染而是根据屏幕刷新率、页面是否有样式变化等因素决定。假设屏幕刷新率是 60Hz大概每 16.7 毫秒给浏览器一次渲染机会。如果事件循环都在处理定时器回调那这一轮可能就没有渲染。这也是为什么 setTimeout 做动画不够稳定你没法精确控制回调到底是排在渲染之前还是之后。想要在渲染之前处理更新逻辑requestAnimationFrame 才是贴近浏览器自身节奏的选择。为了让你彻底看清这个过程我再推演一个稍复杂的例子setTimeout(() console.log(setTimeout 1), 0); Promise.resolve() .then(() { console.log(promise 1); setTimeout(() console.log(setTimeout 2), 0); }) .then(() console.log(promise 2)); console.log(start);第一个宏任务是脚本本身先输出 startsetTimeout(setTimeout 1) 进入宏任务队列Promise 的第一个 then 回调进入微任务队列。脚本结束清空微任务队列取出第一个 then输出 promise 1同时把 setTimeout(setTimeout 2) 放进宏任务队列接着取队里已经存在的第二个 then输出 promise 2。微任务清空后宏任务队列里按顺序排着 setTimeout 1、setTimeout 2于是依次输出。最终顺序是start、promise 1、promise 2、setTimeout 1、setTimeout 2。经常有人在 promise 1 之后去执行 setTimeout 1就是忽略了微任务队列会一次性清空的特点。3.2 setTimeout(0)为什么还能排到后面大家最常用的定时器是 setTimeout但真正理解“定时”语义的人不多。setTimeout(fn, 0) 的实际含义是“至少 0 毫秒之后把 fn 放进宏任务队列”。即使延迟确实是 0它也得等当前宏任务的同步代码全部执行完还得等当前的微任务队列清空才可能被事件循环取出来执行。所以 setTimeout 从来不是“立刻执行”只要前面还有任务它就排队靠后。另外浏览器规范对 setTimeout 还有个嵌套限制当定时器嵌套层级超过 5 层并且调用次数超过 5 次时最小延迟时间会被强制升到 4 毫秒哪怕你传的是 0。为什么要做这个限制是为了防止有人用 setTimeout 搞高频循环把页面拖垮。再加上浏览器对后台标签页的定时器节流策略实际延迟往往会更久。所以别再奇怪“我写了 setTimeout(fn, 0) 为什么还是排在很多代码后面”这不是事件循环不守规矩而是这套规则本来就明明白白写在规范里。注意如果当前宏任务执行了 1000 毫秒即便后面的 setTimeout(fn, 0) 早就到期了也只能等在队列里。主线程被占住时定时器回调不会凭空执行这叫“任务被迫延后”。排查时钟不准的问题第一件事永远是看主线程是不是被长任务堵住了。3.3 Promise 和 async/await 的微任务陷阱接下来是出错率最高的 Promise 和 async/await。很多人以为 Promise 整段都是异步的其实 Promise 构造函数里的执行器是同步执行的。new Promise((resolve) { console.log(sync); resolve(); }) 会在创建 Promise 的当下立刻打印 sync而不是等微任务阶段。进入微任务队列的只是通过 .then()、.catch()、.finally() 注册的回调。构造函数执行器上的同步代码无论如何会先跑完。async 函数也一样。函数体从开头到第一个 await 之前都是同步执行的遇到 await 才挂起挂起点之后的代码会被放到微任务队列。举个例子async function test() { console.log(A); await 1; console.log(B); } test(); console.log(C); // 输出顺序A C BA 在调用 test() 时立刻打印await 1 会被包装成 Promise.resolve(1)挂起点之后的 console.log(B) 被放进微任务队列test() 返回后的同步代码继续打印 C当前宏任务结束后清空微任务B 才打印。这里最违反直觉的地方在 await 右边如果是一个同步返回值它也会产生一次微任务切换所以 B 绝不会在 C 前面出现。记住这个原则之后再去读那些 async 函数嵌套的面试题基本能一眼看穿。说到 Promise 和事件循环的关系还有一个高频考点如果两个 Promise 在同一轮微任务队列里它们按注册顺序执行但如果一个 Promise 的 then 里注册了另一个 Promise 的 then新注册的会被加到队尾。增加队列语义的理解对分析复杂时序很有帮助。4. Node.js 的事件循环和浏览器不太一样4.1 libuv 的六个阶段是怎么转的浏览器里谈事件循环通常直接说调用栈加两个队列就够了换到 Node.js这套模型要再细化一层因为 Node 底层用的不是浏览器那套而是 libuv 实现的事件循环。它的核心是六个阶段按顺序循环timers 处理 setTimeout 和 setInterval 的到期回调pending callbacks 处理某些被延迟的系统回调idle/prepare 是 libuv 内部使用业务代码基本接触不到poll 是最繁忙的阶段负责等待并处理文件 I/O、网络 I/Ocheck 阶段负责 setImmediateclose callbacks 处理 socket 等资源的 close 事件。poll 阶段是中文资料里讲得最少但最核心的部分。代码里遇到文件读取、网络请求一般在 poll 阶段等待结果事件到了就往回调队列里塞。如果 poll 阶段发现没有待处理事件同时队列里也没有别的事情它会在这里阻塞等待而不是空转烧 CPU。只有两种情况会让它提前离开阻塞一是 timer 到期时间到了二是有 setImmediate 回调要执行。这也是为什么 Node 里文件读取回调的执行时机往往比 setTimeout 更不可控因为它依赖系统 I/O 事件的到达时间。在阶段切换之间Node 会做收尾工作。每次一个回调执行完、准备进入下个阶段之前它会先处理 process.nextTick 队列再处理 Promise 等微任务队列然后继续循环。所以你在 Node 里写异步代码输出优先级天然是 nextTick 最高、Promise 次之、宏任务兜底。这和浏览器侧不太一样浏览器里只有微任务和宏任务两层Node 里则多了进程级别的 nextTick 层。4.2 process.nextTick 为什么会插队process.nextTick 是 Node 独有字面上看像定时器但它既不是宏任务严格说也不是标准微任务。它的特点是出现在每次阶段切换之前而且必须把当前 nextTick 队列全部执行完才会继续处理 Promise 的微任务队列。也就是说它比 Promise 优先级还高。为什么 Node 要提供这个东西是为了让某些初始化操作能在当前操作完成、事件循环继续之前立刻跑完避免关键逻辑被推迟到后面的阶段。缺点是显而易见的高优先级意味着高风险。如果在 process.nextTick 里递归调用 process.nextTick就会不断往队列里塞任务事件循环永远没机会进入下一个阶段整个进程都会被饿死。官方文档明确警告不要递归使用 nextTick。我见过有新手图方便用 nextTick 做异步递归遍历结果进程直接卡死连日志都不输出。排查思路也很简单先看 CPU 是不是被打满再确认是不是有 nextTick 递归。与之相对的是 setImmediate它是正经宏任务做法是在 check 阶段执行回调。一个很经典的结论是在 I/O 回调内部setImmediate 几乎总是先于 setTimeout 执行原因就在阶段顺序I/O 回调运行在 poll 阶段setImmediate 会在紧接着的 check 阶段执行而 setTimeout 要等下一轮循环的 timers 阶段。这个细节在 Node 面试题里出现频率极高值得单独记一下。4.3 浏览器和 Node 的判断口诀写业务代码的时候到底按哪一套理解其实大多数前端业务代码里两边行为高度一致。浏览器没有 process.nextTickNode 没有 MutationObserver但 Promise 和 setTimeout 的通用规则是相同的所以核心思想“宏任务结束清空微任务”可以放心用。真正的差异集中在 setImmediate 和 process.nextTick 这一类 Node 独有机制上。我常用的一句话概括记忆法同步代码先跑完微任务清空再渲染宏任务一轮取一个nextTick 永远最靠前。这句话不够严谨到能覆盖所有阶段细节但对日常理解足够用了。真正写出 Node 后端的高频异步任务再回头深究 libuv 每个阶段的队列也不迟刚开始没必要一头扎进源码里。5. 实战中有问必答常见问题与排查实录5.1 页面卡死和栈溢出的现场处理实战里最常见的案发现场是“页面白屏、卡死、CPU 打满”。很多人第一反应是网络问题其实很多时候是主线程被长期占用。比如一段没有任何退出条件的 while(true) 循环或者递归解析函数把栈挤爆事件循环根本没机会把 requestAnimationFrame、事件监听、定时器拿出来执行渲染自然也不会发生。没有报错不代表没出问题这种阻塞往往是最难排查的。排查思路第一步是用开发者工具的 Performance 面板录制调用记录找哪一帧耗时异常。第二步是任务拆分。如果有一个超大数组要循环处理可以把循环切成若干片每处理几千条就把剩余部分通过 setTimeout 塞回宏任务队列让浏览器在间隙里有机会渲染和响应。更彻底的做法是把纯计算丢给 Web WorkerWorker 和主线程互不阻塞。我处理过一个报表导出功能十万行数据在 Worker 里生成页面完全没有卡顿。如果你遇到的是 RangeError: Maximum call stack size exceeded优先考虑递归是不是没有终止条件或者递归深度确实太大。改写成迭代或显式队列往往能解决不要靠加大定时器延迟来糊弄问题那样只是把栈爆的时间往后拖。5.2 接口返回顺序错乱怎么查前端最容易出现的异步 bug 是请求返回顺序错乱。比如页面同时发三个请求B 请求数据量小但先返回A 请求数据量大后返回你如果按“发出的顺序”去处理数据就会拿到错位的信息。关键要意识到事件循环只保证回调执行的动作按入队顺序来不保证网络 I/O 返回的先后。网络请求什么时候返回取决于服务端处理速度和链路延迟跟注册回调的顺序没有任何关系。处理方案也固定如果多个请求之间没有依赖关系但需要全部拿到用 Promise.all 或 Promise.allSettled 等待所有任务结束如果有依赖关系就把接下来的操作放到 await 之后串行执行如果只是担心过期请求覆盖新结果可以用 AbortController 取消前一个未完成的请求或者在回调里判断这一次请求是否仍然是最新发起的。排查时先在 Network 面板看每个请求的实际完成时间再去代码里看哪里依赖了完成顺序。别一上来就把锅甩给事件循环它只排队不做乱序。5.3 定时器失效的原因不只是缩进setInterval 和 setTimeout 不准时是重灾区。我用 setInterval 写过心跳上报后来发现日志里的上报间隔竟然有两秒多检查半天才发现不是心跳逻辑错了而是每个上报回调里有一段同步重计算逻辑耗时直接超过一秒。setInterval 的机制是每隔一段时间往宏任务队列塞一次回调它根本不在乎前一个回调有没有执行完。如果前一个回调还占着调用栈下一个回调就只能等时间一长任务堆积内存也跟着报警。标准解法是把 setInterval 改成“setTimeout 递归安排下一次”的模式每次回调处理完之后再自调一次 setTimeout保证上一轮工作结束才开始下一轮。这相当于把“固定频率”改成“固定间隔”对不稳定任务更友好。另外要留意浏览器后台标签页的节流策略。很多浏览器会把后台定时器频率限制到每秒一次甚至更低你以为逻辑还在跑其实它被降频了。对精度要求高的动画和倒计时别依赖 setTimeout优先用 requestAnimationFrame 配合 performance.now() 去校正时间。5.4 长任务拆分的三种落地姿势长任务拆分这件事我总结成三种落地方式。第一种是切片循环把原本一个 for 循环拆成多个小的循环片段每执行完一段就把下一段放进宏任务队列尾部让浏览器在间隙里有机会处理其他任务。第二种是空闲期填充用 requestIdleCallback 把低优先级任务放到浏览器真正空闲的时候执行适合处理埋点上报、日志整理这种稍后完成的活儿。第三种是 Web Worker把纯计算搬到独立线程最彻底代价是数据要结构化克隆不适合高频传递大对象。三种方案我用一句话概括任务能碎就碎不能碎就挪挪不动就换线程。日常页面里八成以上的卡顿问题都能靠这三招缓解。事件循环不是可以随便请求帮忙的系统它是唯一主线程的调度员只有把任务安排得忙闲均匀页面才会流畅。6. 我自己踩过的坑和一些实用建议说实话这些理论我最早全是在面试前背下来的真正用进项目里是吃了两次亏才记住的。一次是写数据同步脚本每隔几秒用 setInterval 塞一批同步任务结果遇到网络慢加上回调内部嵌套异步操作任务不断堆叠内存一路涨最后服务直接跑挂。那次之后我把所有周期任务都改成 setTimeout 递归安排下一次再没遇到过任务堆叠。另一次是排查白屏问题找了半天发现是一个校验大数组的 while 循环在阻塞主线程渲染根本没机会执行才真正意识到所谓“页面崩溃”有时候只是事件循环被逼停了。所以我的建议是不要只背输出顺序题最好亲手写一组包含 setTimeout、Promise、async/await、requestAnimationFrame 的测试代码在控制台里看一遍输出再对照队列模型一步一步推演。刚开始推不对特别正常我就是从推错再纠正的过程里建立起感觉来的。写异步代码时给自己立几条规矩定时器回调里别再套定时器、周期任务一律用 setTimeout 自调度、所有长循环必须能说清楚自己什么时候让出主线程。做到这三条你踩的坑会少一大半。
返回列表