尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

事件循环:从浏览器到 Node.js,彻底理解宏任务与微任务的执行顺序

事件循环:从浏览器到 Node.js,彻底理解宏任务与微任务的执行顺序 1. 为什么说事件循环是 JS 和 Node 的“心脏”1.1 从“单线程”这个原罪说起很多刚接触 JavaScript 的人都会有一个困惑明明 JS 只有一个线程为什么还能一边处理点击事件、一边发网络请求、一边跑动画答案就是事件循环Event Loop。这个机制其实没那么玄乎我习惯把它理解成“一个排队叫号的调度员”——它决定了代码里那么多任务到底谁先执行、谁后执行、谁要等着。先说个最常见的场景。你用浏览器打开一个页面页面上同时要跑一段耗时很长的 for 循环还要监听用户点击。如果 JS 真的只有一个线程而且所有任务都挨个排队执行那么 for 循环没跑完点击事件就永远得不到响应页面会直接卡死。事件循环存在的意义就是让“会阻塞的代码”靠边站让“通知类、回调类”的代码插队进来让整个程序看起来像同时干了很多事。到了 Node.js 这边这套机制被进一步放大了。Node 的文件读取、网络请求、数据库操作全都是异步的底层通过 libuv 实现事件循环调度。你写的fs.readFile并不是真的“读完了再继续”而是“告诉系统我要读文件等读完了再叫我”。这个“等完了再叫我”的过程就是事件循环在背后运作。1.2 谁需要把这件事搞明白我可以直接说以下三类人必须把事件循环彻底吃透前端开发者你在浏览器里写setTimeout、Promise、async/await如果不知道执行顺序早晚会被诡异的 Bug 坑哭。Node.js 后端开发者你写接口、写定时任务、处理消息队列如果不懂事件循环根本没法解释“为什么定时器不准”“为什么高并发下内存暴涨”“为什么回调地狱能写成那样”。面试者事件循环是前端和 Node 方向的高频考点从初级到高级都可能被问到。网上相关的面试题一抓一大把但能把原理讲清楚、还能结合代码分析顺序的人其实不多。这篇文章我不会只给结论我会从原理到代码、从浏览器到 Node把整套事件循环机制拆开讲清楚。你会看到调用栈Call Stack、任务队列Task Queue、微任务Microtask、宏任务Macrotask、process.nextTick、setImmediate这些概念到底是怎么串起来的。2. 事件循环的最小模型调用栈 任务队列2.1 调用栈到底在干什么先建立一个最基础的模型。JS 引擎在运行代码时维护了一个调用栈Call Stack这个栈的特点是“后进先出”。函数 A 调用了函数 BB 调用了函数 C那么栈里从下到上就是 A、B、CC 执行完弹出再执行 B最后 A。这个栈里装的都是“正在执行的函数”它是同步的世界。问题来了如果某个函数里有个耗时的操作比如一个三秒的循环那调用栈就会被它一直占住期间什么都不能干。这就是为什么“同步代码阻塞”会成为前端性能的头号敌人。事件循环的第一个核心动作就是“调用栈为空时从任务队列里取出下一个任务执行”。这句话你记牢了后面所有复杂的规则都是围绕它展开的。2.2 两种任务宏任务和微任务你肯定听说过这两个词宏任务Macrotask和微任务Microtask。它们之间的区别直接决定了几行代码的执行顺序。我用生活场景打个比方。你把工作分成两种一种是“正常排队办业务的人”一种是“有 VIP 通道的人”。宏任务就是普通排队的人微任务就是走 VIP 通道的人。每一个宏任务执行完之后事件循环不会立刻去拿下一个宏任务而是先把当前这个宏任务产生出来的“所有微任务”全部清空然后再进入下一个宏任务。浏览器里常见的宏任务有setTimeout、setInterval、I/O 事件、UI 渲染、MessageChannel。常见的微任务有Promise.then、MutationObserver、queueMicrotask、async/await中 await 之后的代码。实际看几个代码你就彻底明白了。console.log(1); setTimeout(() { console.log(2); }, 0); Promise.resolve().then(() { console.log(3); }); console.log(4);这个输出顺序是1 4 3 2。过程拆一下同步代码先执行console.log(1)输出setTimeout注册一个宏任务扔进宏任务队列Promise.then注册一个微任务扔进微任务队列console.log(4)输出。同步代码跑完调用栈空了先清空微任务输出3然后再从宏任务队列里拿出setTimeout的回调输出2。这个例子几乎是面试题里的“hello world”但很多人只是背答案不知道背后的逻辑。你现在看懂了后面遇到更复杂的嵌套题目也能自己推。2.3 为什么微任务要“插队”优先执行我再说深一层为什么微任务要优先于宏任务这其实是设计上的有意为之。微任务的设计初衷是为了处理“同一个宏任务内部产生的连续性操作”。比如一个 Promise 链式调用then里又返回一个 Promise再then。如果微任务不插队而是被排到宏任务队列后面那整个 Promise 链的效率会大打折扣而且用户感知到的延迟会变大。你再想想await的本质是什么await的后面那部分代码在编译层面就是被包进一个微任务里的。这也是为什么很多人用async/await写代码时总觉得“代码看起来是同步的实际上它是异步的”——你看得见的地方是同步写法看不见的地方全是微任务调度。3. 浏览器事件循环比你想的更现实3.1 渲染、事件、定时器如何共存浏览器的环境比 Node 复杂因为浏览器除了要执行 JS还要负责渲染页面、处理用户输入、跑动画。这意味着事件循环不仅要管 JS 代码还要和“渲染更新”协调。标准模型是这样的每执行完一个宏任务浏览器会检查是否需要重新渲染。如果需要就在进入下一个宏任务之前渲染一次。这个“渲染时机”是微任务清空之后、下一个宏任务开始之前。所以你会发现如果你在一个微任务里修改了 DOM可能不会立刻触发渲染而是要等多个微任务跑完才渲染。这里有一个非常经典的坑用setTimeout实现“等渲染完成后再执行”。比如你想要一个元素从 A 状态变成 B 状态后再读取它的尺寸。如果你用同步代码改完样式立刻读读到的是旧值如果你用setTimeout(..., 0)因为宏任务执行完会先渲染所以你读到的就是新值。这个技巧在老项目里很常用现在也可以用requestAnimationFrame或者双 rAF来代替但原理是一样的。3.2 别再被“setTimeout 等于准时执行”骗了我必须强调一件事setTimeout的第二个参数只是“最小延迟时间”不是“保证执行时间”。调用栈里有大量同步代码在跑时定时器的回调到点了也只能等着。比如你写一个setTimeout(fn, 0)但前面有一段 2 秒的同步循环那fn一定在 2 秒后才执行而不是“立刻”。这个特性在生产环境里特别容易坑人。举个例子你做图片懒加载用一个轮询去检测图片是否进入视口。你本来想着每 100ms 检查一次结果某个时间里主线程被一段重计算阻塞了 1 秒那这 1 秒内定时器全部堆积等主线程腾出来连续跑好几个定时器回调表现就是“一段时间没反应然后突然爆发执行”。真正需要平滑的东西我不会依赖setTimeout宁可把它换成requestAnimationFrame加上时间差判断。再补充一个细节HTML 规范里对嵌套的setTimeout有最少 4ms 的限制。意思是当定时器回调里又创建一个定时器并且嵌套层级超过 5 层浏览器会强制把延迟时间下限提到 4ms。这个限制不是为了坑你而是为了保护 CPU 和电池防止页面因为疯狂的定时器而耗尽资源。所以你在写游戏循环或者高频轮询时setTimeout并不是一个精准的工具。3.3 事件循环里的“饿死”风险微任务虽然插队快但它有个致命问题如果微任务队列永远清不空宏任务就永远没有机会执行。function loop() { Promise.resolve().then(loop); } loop();这段代码会不断往微任务队列追加新任务结果主线程永远在处理微任务定时器、点击事件全部停在队列里。在浏览器里这个页面会直接失去响应。这是“饿死”starvation现象写代码的时候要格外注意。我见过一些同事用微任务做“递归遍历大数据”一不小心就会把一个表格页面卡死在加载动画里排查半天才发现是微任务递归导致宏任务排队排不上。4. Node.js 事件循环六个阶段一个都不能少4.1 libuv 和它的六个阶段进入 Node.js你会发现事件循环变成了一个更加“工业化”的调度器。Node 基于 libuv 这个 C 语言库实现事件循环整套机制被划分为六个阶段循环本身就是这六个阶段按顺序转圈跑┌───────────────────────────┐ ┌─►│ timers │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ poll │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ check │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ close callbacks │ │ └───────────────────────────┘ └──────────────────────────────┘我依次解释一下每个阶段重点讲 poll 和 check因为这两个阶段是编码时最容易接触到的。timers 阶段处理setTimeout和setInterval到期的回调。pending callbacks 阶段处理系统操作的回调比如 TCP 错误、文件系统错误等。idle, prepare 阶段仅供 libuv 内部使用你写业务代码基本碰不到。poll 阶段核心阶段。它会获取新的 I/O 事件、处理 I/O 回调同时也会等待新的 I/O 事件发生。如果没有定时器到期poll 会一直阻塞等待。check 阶段处理setImmediate回调。注意setImmediate不是setTimeout的替代品它有自己的调度位置。close callbacks 阶段处理close事件比如 socket 关闭process.on(exit)之类的清理回调。4.2 process.nextTick 是“插队的VIP中的VIP”Node 里有一个.nextTick机制。先说结论process.nextTick在技术上并不属于事件循环的任何一个阶段它是在每个阶段之间被优先执行的。也就是说任何阶段运行完接下来都是先处理process.nextTick队列然后再进入下一个阶段。这就导致了一个特性process.nextTick的优先级比Promise.then还要高。process.nextTick(() { console.log(nextTick); }); Promise.resolve().then(() { console.log(promise); });输出顺序是nextTick在前promise在后。原理是事件循环每进入一个阶段做完该阶段的任务后在处理微任务队列之前先会把整个process.nextTick队列清空。如果你在process.nextTick回调里又调用了process.nextTick那事件循环会一直清下去直到队列空为止。这里有个风险无限递归的process.nextTick比微任务递归更容易卡死进程。在 Node 文档里官方甚至专门警告过process.nextTick在递归场景下会导致 I/O 操作永远得不到执行。所以我会告诉你一个经验法则能用setImmediate解决的事情不要用process.nextTick。process.nextTick适合的场景是“在当前代码执行完之后、事件循环继续之前必须立即回调”的情况比如某些内部错误处理、流式接口的断点清理。4.3 setImmediate 和 setTimeout 到底谁先执行这是一个让无数人纠结的问题。在 Node 里setImmediate和setTimeout(cb, 0)的顺序在不同的场景下结果是不同的。如果你在模块的顶层即主模块调用它们// 在 main.js 里 setTimeout(() { console.log(timeout); }, 0); setImmediate(() { console.log(immediate); });这个结果不固定。可能是timeout先也可能是immediate先。原因在于启动进程后进入事件循环的时机受系统性能影响timers 阶段执行时定时器是否已经到期并不确定。如果你在 I/O 循环内部调用同样的代码const fs require(fs); fs.readFile(__filename, () { setTimeout(() { console.log(timeout); }, 0); setImmediate(() { console.log(immediate); }); });这次结果几乎永远是immediate先输出。因为fs.readFile的回调在 poll 阶段执行poll 阶段结束后会立即进入 check 阶段而setImmediate的回调恰恰就在 check 阶段执行定时器要等到下一次循环的 timers 阶段才轮到。这个“固定顺序”背后就是阶段推进的逻辑。实际编码中除非你在做框架或者底层工具否则没必要纠结这个顺序。但在面试里这个点经常被拎出来考所以我把底层逻辑讲明白了你就不怕被反复追问。4.4 文件读取到底走了哪条路径我再用一个实例帮你把整套流程串起来。const fs require(fs); console.log(start); fs.readFile(__filename, () { console.log(readFile callback); }); setTimeout(() { console.log(timeout); }, 0); setImmediate(() { console.log(immediate); }); process.nextTick(() { console.log(nextTick); }); Promise.resolve().then(() { console.log(promise); }); console.log(end);输出顺序是start end nextTick promise readFile callback immediate timeout这里有一个很关键的点readFile的回调出现在immediate和timeout之前。为什么因为文件读取是异步 I/O它是在内核层面完成读操作后在 poll 阶段触发回调。而 poll 阶段在 check 之前、下一次 timers 之前。所以顺序是poll 阶段执行readFile回调然后 check 阶段执行setImmediate最后下一轮循环的 timers 阶段执行setTimeout。这个例子能帮你把抽象的阶段设计和真实代码对应上我建议你亲自跑一遍感受一下输出顺序再对着阶段图去推。5. Node 与浏览器有相似更有差异5.1 都是事件循环差别在哪浏览器的事件循环以“渲染”为重要节点Node 的事件循环以“系统 I/O 的调度”为核心。两者最大的差异在于浏览器有微任务清空动作但微任务不是在一个独立阶段而是每个宏任务之后执行Node 里process.nextTick优先级最高Promise 微任务在各阶段之间执行。Node 没有渲染线程所以不需要考虑 UI 更新的时机。Node 有setImmediate浏览器没有浏览器有requestAnimationFrameNode 没有。浏览器的宏任务通常被理解为“用户交互事件、定时器、UI 渲染”Node 的宏任务被明确划分为 phase。5.2 三个“千万别”的经验总结我在实际开发里总结过几个容易踩的坑写在这里第一别在 Node 里用全局Promise递归处理超大列表。很多人图省事把列表分片后一个个 await遇到几十万条数据就彻底崩了。原因是微任务队列被塞满I/O 回调完全没法插入。正确处理是分块配合setImmediate或者用流式处理让出控制权给事件循环。第二别依赖setTimeout做精确调度。做定时任务、延迟重试、限流时setTimeout的误差在 Node 里也是存在的而且受 CPU 负载影响很大。如果你需要精确间隔可以考虑基于时间戳的循环检查或者直接引入 cron-like 的调度库。第三别在nextTick里做递归和重逻辑。一旦不停往nextTick队列里追加整个进程的 I/O 都会被饿死。Node 依赖异步 I/O 作为核心能力你把 I/O 饿死等于把服务搞瘫。6. 实战为什么你的定时任务总不准、高并发下卡顿6.1 定时任务不准根源在“忙等”很多 Node 后端会在项目里用setInterval写定时任务。比如每 10 秒扫一次数据库里的待处理订单。表面上看代码很简单setInterval(async () { const orders await getPendingOrders(); await processOrders(orders); }, 10000);然而如果processOrders一次处理了特别多订单耗时超过 10 秒那么下一次setInterval触发时上一个执行还没结束。这时就会发生“回调堆积”事件循环发现定时器到期后开始执行回调但回调内部又在做异步等待导致这段期间内新的定时器回调又被标记为到期等上一次做完紧接着又立刻跑下一次。表现就是你明明设置了 10 秒间隔实际执行可能变成“做完一次紧接着做第二次”没有休息时间。解决方案至少有两种动态调度每次执行完再设置下一次定时器用setTimeout代替setInterval。节流标志位用一个布尔变量锁住防止上一次没结束就启动下一次。这两种方法我都在生产环境用过第二种更简单但第一种更优雅。从事件循环的视角看它们本质都是避免“调用栈和任务队列之间的恶性堆积”。6.2 高并发“卡顿”先查主线程阻塞你可能见过这种情况Node 进程 CPU 打满但很多请求的响应时间飙升到秒级。这时候先别急着调数据库连接数十有八九是主线程被同步阻塞了。比如你在一个请求里对一个大数组做了深拷贝、复杂 JSON 解析、或者某种加密计算这些操作都是同步占用调用栈的。一个请求卡 2 秒后面所有请求的异步回调全部排队整个服务就像堵车一样。Node 的异步优势只能体现在“非 CPU 密集型”场景。一旦遇到 CPU 密集型操作你会发现事件循环的调度能力再强也没用。这类问题的排查思路很固定先用--prof或者火焰图工具定位热点函数然后把这个同步操作拆分成异步分片处理、交给 worker_threads或者干脆换用其他语言/服务来做。事件的循环本身没有问题问题是你的同步代码“占着茅坑不拉屎”。6.3 用它理解“背压”和“流式处理”再往深一点说事件循环的核心价值之一是让“背压”Backpressure成为可能。所谓背压就是数据生产者速度太快消费者处理不过来时应该有一种机制让生产者“慢下来”。Node 的 stream 模式天然利用了事件循环的暂停和恢复机制。可读流数据到达时会触发data事件它们在 poll 阶段被调起当你调用pause()时相当于不再处理data事件的回调这些回调积压在队列里当你resume()时再重新开始处理。理解事件循环你就明白为什么pipe不会直接把内存撑爆——它就是在消费速度跟不上时让源停了下来。这个点在做大文件处理、日志导入、数据库批量写入时非常有用。我以前写过一段同步写法从日志文件里解析逐行数据然后插入数据库几十万行日志直接把 Node 进程卡到超时。后来改成流式读取 异步写入 暂停恢复控制内存占用从几百兆降到了几十兆处理速度反而快了不少。核心就是让事件循环始终保持“有节奏地干活”而不是一次性把所有东西都压进内存和队列里。7. 工具与调试亲手摸一次事件循环7.1 用代码验证执行顺序如果你想把理论变成自己的东西最快的方法就是自己写几个测试用例。// 这是我最常用的一个调试组合 console.log(start); setTimeout(() console.log(timer1), 0); setImmediate(() console.log(immediate1)); Promise.resolve().then(() { console.log(promise1); process.nextTick(() console.log(nextTick inside promise)); }); process.nextTick(() console.log(nextTick1)); fs.readFile(__filename, () { console.log(readFile); setTimeout(() console.log(timer inside readFile), 0); setImmediate(() console.log(immediate inside readFile)); process.nextTick(() console.log(nextTick inside readFile)); });把这段代码跑一遍你会看到process.nextTick的各种层级优先级也能看到 I/O 回调内部的定时器和setImmediate的顺序。我建议你一边跑一边画图把每个输出挂到六阶段图里你会彻底理解“阶段切换 插队队列”是怎么配合的。7.2 用调试器观察任务队列真的去“看”事件循环内部状态并不容易但 Node 提供了不少间接手段。我常用的方法在回调开头和结尾分别打印时间戳测算一个阶段里所有回调的总耗时。使用process._getActiveHandles()和process._getActiveRequests()查看当前活跃的句柄和请求。用--trace-events-enabled生成 trace 文件在 chrome://tracing 里查看事件循环各阶段的耗时分布。这三种方法我实际用过最多的是第一种最简单粗暴第三种最专业可以精确看到 timers 阶段和 poll 阶段分别花了多少毫秒。如果你排查“高并发响应慢”的问题trace 文件能帮你一眼定位是 poll 阶段等太久还是业务回调本身太慢。7.3 一个派得上用场的性能小技巧要减少事件循环的无效空转有一个细节别用setInterval去做高频轮询。每次轮询都会唤醒事件循环即使无事可做也要跑一遍。优先级更高的做法是用“事件驱动”来替代轮询。比如监听文件变化用fs.watch不要用setInterval去一遍遍读文件状态监听消息队列用订阅不要用轮询拉取。再比如Node 的poll阶段在没有定时器、没有待处理 I/O 时会阻塞等待事件。这时它不消耗 CPU。而如果你挂了一个高频setIntervalpoll 阶段每次都要被 timers 阶段打断整个循环就总处于“醒来干活”的状态CPU 空转明显。以前我写过一个守护脚本轮询间隔从 1 秒改成事件驱动后CPU 使用率从 8% 直接降到接近于 0。8. 常见问题与排查清单8.1 输出顺序永远搞不清怎么办只要你还在写 JS事件循环的乱序问题就会一直存在。我给你的排查顺序是先把同步代码跑出来的输出写下来。再找process.nextTick它的优先级最高排最前。再找 Promise / await 之后的微任务。然后判断代码运行的“位置”如果在模块顶层setTimeout和setImmediate谁先谁后都不一定如果在 I/O 回调里setImmediate几乎总是先输出。最后再补充定时器的嵌套延迟影响。这个顺序可以作为“思维模板”遇到任何乱序题套进去基本都是对的。8.2 setImmediate 和 nextTick 到底选谁我直接给结论需要在当前操作之后、I/O 事件之前执行回调用process.nextTick。例如 EventEmitter 的错误处理和清理工作。需要把任务放到下一轮事件循环不要阻塞当前阶段用setImmediate。例如“超大列表分片处理”“让出 I/O 轮流执行”。一句话口诀nextTick是“尽快”setImmediate是“不要阻塞”。8.3 会不会有“任务”被永久丢弃正常情况下不会事件循环会保证队列里的任务得到处理。但有一个例外如果进程在某个回调里直接抛异常且没有捕获Node 进程会直接退出之后的任务全部不会执行。这就是为什么在 Node 服务里你永远需要一个兜底的process.on(uncaughtException)和process.on(unhandledRejection)把错误记录下来后决定是否优雅退出而不能让整个服务突然暴毙。另外提醒一下不少人会把process.nextTick和 Promise 当成“可以无限追加任务”的队列用这是一个非常危险的习惯。事件循环再能调度也经不起无穷无尽的插队。8.4 生产事故排查速查表现象可能原因排查方向定时任务执行频率暴涨回调执行时间长于间隔任务堆积改用执行完再setTimeout加锁高并发下响应变慢主线程被同步 CPU 操作阻塞用 profile 定位热点异步分片I/O 操作迟迟不回调微任务或 nextTick 死循环检查递归代码强制让出控制权事件循环 CPU 空转高频轮询导致反复唤醒用事件驱动替代 setInterval大量请求同时超时同步逻辑 任务堆积增加 worker拆分阻塞逻辑这张表是我踩过坑之后的总结。碰到诡异问题我会先想“事件循环现在在干嘛”而不是直接看业务代码。大部分奇怪的现象追到根源都和循环卡顿、任务堆积、阶段顺序有关。9. 回到开头事件循环对你意味着什么写了这么多我不太想做什么总结就分享一个经验好了。我刚接触 Node 的时候也不太理解为什么要研究事件循环这种东西。直到有一次在线上环境排查一个定时任务的重复执行问题翻遍了代码也没找到毛病最后才发现是setInterval回调里异步任务堆积导致的“连着跑”。也就是从那次开始我痛下决心把 libuv 的阶段模型啃了一遍。啃完之后很多以前只靠背结论的问题比如setImmediate和setTimeout谁先执行一下就通了因为你不再是在猜而是能看到代码在阶段图里的位置。现在的我会建议所有做 JS 和 Node 的开发者无论前端还是后端花一个下午把事件循环这套东西彻彻底底过一遍。你不需要把每一行 C 代码都看懂但你需要理解“调用栈空了才取任务”“微任务优先”“Node 有六个阶段”这几句核心的话。它们会把你在代码里遇见的很多“灵异事件”变成可解释、可推理的常规现象。最后再送一个小技巧遇到执行顺序问题别急着问别人先自己用console.log把关键节点打点再对照六阶段图走两遍。走到第三遍的时候你会发现自己已经能“看见”事件循环了。到那个状态面试和排障都会轻松很多。
返回列表