1. 事件循环是什么:先看一个让你意外的执行顺序
先上代码,你别急着往下翻,心里先默念一下这段代码的输出顺序是什么:
console.log('1'); setTimeout(() => { console.log('2'); Promise.resolve().then(() => { console.log('3'); }); }, 0); Promise.resolve().then(() => { console.log('4'); setTimeout(() => { console.log('5'); }, 0); }); console.log('6');如果答案是1 6 4 2 3 5,那你可以直接跳过前两章,去后面看性能调优和 Node.js 场景的区别。如果脑子里是别的顺序,或者你还在想"Promise 不是同步就执行了吗"这类问题,那这篇就正好对症。
这段代码几乎每年面试都会出现,但如果你真去跑一遍就会发现,事件循环不是一个背结论就能搞清楚的东西。它背后是一整套"谁先谁后"的调度规则,而理解这套规则,直接决定了你在处理异步竞态、性能瓶颈、甚至诡异线上 bug 时,是有章法地排查,还是靠瞎猜和console.log碰运气。
我见过太多把setTimeout当"延时执行"的同学,也见过不少把async/await当成"阻塞等待"的同学,这两种理解在简单场景下都"能用",但一旦遇到并发请求、IO 密集任务、或者需要在某个容器里协调多个异步流程时,分分钟翻车。事件循环就是那个幕后的总调度员,你要跟它处好关系,就得先摸清它的脾气。
这一篇我们不做概念搬运,直接从执行模型开始讲清楚:调用栈、任务队列、微任务这三样东西到底是怎么配合的,然后我会用真实场景里的坑来说明,为什么事件循环的知识不是纸上谈兵。
2. 调用栈与任务队列:事件循环运行的底层模型
2.1 单线程不是"一次只能干一件事"
很多文章一上来就说"JavaScript 是单线程的",这句话没毛病但容易误导。单线程指的是主线程只有一个调用栈,同一时刻只能执行一段代码,但这不代表 JS 只能顺序干等。真正让 JS 能够一边跑业务逻辑,一边处理网络请求、定时器、用户点击的,是"调用栈 + 事件循环 + 任务队列"这套组合拳。
先看调用栈。JavaScript 引擎(比如 V8)在运行代码时会维护一个栈结构,函数调用就是入栈,函数返回就是出栈。栈顶是当前正在执行的代码。看这个最简单的例子:
function a() { console.log('a running'); b(); } function b() { console.log('b running'); } a();执行过程是:a入栈,打印日志,接着b入栈,打印日志,b返回出栈,a返回出栈。整个过程是严格同步的,栈不空,后面的代码就别想执行。
问题来了:如果某个操作特别耗时,比如要等一个网络响应、读一个大文件、或者等一个定时器到点,难道要让整个页面"卡住"直到完成?显然不行。这时候 Web API 和事件队列就登场了。浏览器或者 Node.js 环境会额外提供一些线程(或者底层的能力),把这类需要等待的操作放到环境自己去处理,等操作完成后再把对应的回调函数扔进"任务队列"里排队,而不是直接塞回调用栈。
2.2 为什么是"队列"而不是"另一个栈"
这里有个常见误区:任务队列不是随便往里塞就行。它的设计是先进先出(FIFO),也就是说,先到达的回调会先被取出执行。这个设计是有道理的:哪个任务先完成,就先处理哪个,避免饿死早期发出的请求。
于是事件循环的工作模式就清楚了:调用栈为空时,从任务队列头部取出一个任务,推入调用栈执行。执行完,栈清空,再取下一个。这个循环往复的过程就是事件循环。
但如果你只理解到这里,还远远不够。因为任务队列并不是一个,而是分了两层:宏任务队列(macrotask queue)和微任务队列(microtask queue)。
2.3 关键分层:宏任务与微任务
宏任务包括:setTimeout、setInterval、setImmediate(Node),以及浏览器里的用户交互事件、IO 回调、requestAnimationFrame等。微任务包括:Promise.then回调、queueMicrotask、MutationObserver,在 Node 里还有process.nextTick(这个稍后单独说)。
两类任务的执行规则是:
- 执行完一个宏任务后,事件循环不会立刻去取下一个宏任务,而是先清空整个微任务队列。
- 如果微任务队列在清空过程中产生了新的微任务,那么这些新微任务也会在当前这个循环里被清空,直到微任务队列彻底为空。
- 之后才会去宏任务队列里取下一个宏任务。
用大白话说:微任务的优先级高于宏任务,而且微任务可以"插队"在宏任务之间。为什么会这样设计?因为微任务通常代表着"和当前操作结果直接相关的后续逻辑",比如 Promise 决议后的回调,处理得足够快,应用就能更及时地拿到最终状态。如果回调被塞到宏任务里,那每次 Promise resolve 都要等一轮事件循环,体验和性能都会很怪。
2.4 用生活类比理解事件循环
你把 JS 主线程想成一个只有一个窗口的银行柜台。客户的"业务办理"就是调用栈里的函数。一个客户正在办理业务的时候(正在执行),其他客户只能排队。宏任务队列就是外面大厅的普通排队通道,大家按到达顺序排队。而微任务队列是柜台旁边的"VIP 快速通道"——每当柜台办完一个人,保安(事件循环)会先看看 VIP 通道有没有人,有的话就让他们先办,而且中间可以一直插新 VIP,直到 VIP 通道空掉为止,才回到大厅喊下一位。
setTimeout(0)的本质不是"立刻执行",而是"立刻排到普通队伍末尾"。这个理解偏差导致了很多 bug。
3. 微任务与宏任务的交锋:一张图之外的执行细节
3.1 开头那段代码的逐行拆解
现在回到开头的代码,我们一步步推演。先列出所有代码元素:两个setTimeout的回调,两个Promise.then回调,还有先行的同步打印。
- 同步代码执行:
console.log('1')入栈,打印 1。 - 遇到第一个
setTimeout,交给浏览器环境计时(0ms 后触发),回调 A 进入宏任务队列。 - 遇到第一个
Promise.resolve().then:Promise 本身立即 resolved,但.then的回调 B 是异步的,进入微任务队列。 - 执行
console.log('6'),打印 6。 - 同步代码执行完毕,调用栈为空。事件循环先检查微任务队列,里面有回调 B,取出执行:打印 4。然后因为
setTimeout回调和 Promise 回调里嵌套的setTimeout,这里要注意回调 B 里还塞了一个宏任务回调 C,它被加入宏任务队列(排在回调 A 之后)。 - 微任务队列清空。取宏任务队列的第一个——回调 A,执行:打印 2,接着遇到
Promise.resolve().then(() => console.log('3')),这个回调 D 进入微任务队列。回调 A 执行完毕。 - 调用栈为空,又检查微任务队列:取出回调 D,打印 3。
- 微任务清空,取宏任务队列下一个——回调 C,打印 5。
所以输出顺序是 1、6、4、2、3、5。到这一层,已经能看到关键:微任务总在两个宏任务的间隙被优先消化。4 这个输出会排到 2 前面,并不是因为代码注册时间更晚,只是因为它是微任务,排在了宏任务前面。
3.2 async/await 到底在哪个队列
很多同学对async/await理解不够深。它其实是 Promise 的语法糖,但有个细节经常让人掉坑:await后面的代码什么时候执行?
async function test() { console.log('a'); await 42; console.log('b'); } test(); console.log('c');输出是 a、c、b。为什么不是 a、b、c?因为await会让出当前执行权,函数暂停在await处,右侧表达式(这里是42)先求值,然后整个函数剩余部分被包装成一个微任务。所以console.log('b')并不是同步执行的,它会排在微任务队列里,等当前同步代码跑完、调用栈清空之后再执行。
理解了这一点,再看下面这个就容易了:
async function f1() { console.log('1'); await f2(); console.log('2'); } async function f2() { console.log('3'); } f1(); console.log('4');输出顺序:1、3、4、2。因为await f2()会先执行f2()内部的同步代码,打印 3,然后f1暂停,剩下打印 2 的部分进微任务。同步代码打印 4,最后才轮到微任务打印 2。
很多人在做"async/await 并发"优化时,就是因为没有理解这个暂停机制,写出了一堆串行等待的代码,还以为是"并发执行"。
3.3 微任务风暴的风险
微任务可以无限插队的特性,既是优点也是风险。如果微任务队列里每一个微任务执行时又注册了新的微任务,事件循环就会一直卡在"清空微任务"阶段,宏任务永远轮不到。
典型场景:用一个递归的Promise.resolve().then来实现某个轮询逻辑,而轮询的回调里没有异步等待,导致微任务无限自产自销——页面直接卡死。代码大概长这样:
function loop() { Promise.resolve().then(() => { // 某些同步处理 loop(); }); } loop();这一段跑起来,整个页面会直接无响应。这就是为什么不少业务代码里,递归轮询要用setTimeout而不是Promise.then。宏任务之间至少有机会让浏览器处理渲染、响应用户输入,而微任务风暴会把这一切彻底堵死。
我在排查一个长时间挂起的性能问题时,先看堆栈,后面发现罪魁祸首就是某个库内部用了递归微任务做重试。所以记住:微任务是处理"紧接其后"的任务的,不适合用来做需要让出主线程的长周期任务。
4. 浏览器渲染时机:事件循环不只是"执行代码"这么简单
4.1 requestAnimationFrame 和宏任务的关系
如果你认为事件循环只在"取任务、执行任务"之间循环,那浏览器上的表现会打脸。浏览器的事件循环还包含一个非常重要的步骤:渲染更新。
浏览器一般会在合适的时间点(通常是每秒 60 次,对应一帧的产出)执行渲染流程,包括样式计算、布局、绘制等。这个渲染并不是在每次宏任务后都发生的,而是在两帧之间的某个时机统一做。也就是说,一帧时间内可以执行多个宏任务和多个微任务,但渲染不一定每轮都触发,浏览器会根据当前帧的剩余时间、任务耗时等综合决定。
这就带来了一个经典问题:如果你用setTimeout做动画,会有掉帧风险。因为setTimeout回调可能在浏览器渲染周期之外执行,下一帧前可能有两三个setTimeout回调同时被执行,导致动画跳帧。而requestAnimationFrame是在浏览器准备渲染下一帧之前执行,它能确保回调跟上渲染节奏。
从事件循环的角度来理解:requestAnimationFrame回调其实是"一个宏任务",但它的执行时机被浏览器特别安排在了渲染之前。所以如果要在动画里更新元素位置,用requestAnimationFrame是"贴着渲染走",用setTimeout则是"看运气"。
4.2 如何判断代码是否阻塞了渲染
一个很简单的判断方法:打开 DevTools 的 Performance 面板,录制一段时间线,看 Long Task(长任务)的分布。如果发现某个任务的耗时超过 100ms,或者连续几个任务之间没有渲染帧的间隙,那大概率是事件循环被某些同步代码或微任务堵住了。
实战中的感受是:JS 代码本身通常不是最大的瓶颈,真正的问题是"代码让渡事件循环的时机太晚"。比如一个组件在加载时同步处理了一大批数据,调用栈 200ms 不释放,渲染就没法发生,用户看到的自然是白屏或者卡顿。这种情况下,分片处理数据、把非关键逻辑丢到setTimeout或requestIdleCallback里,才能还给事件循环呼吸的缝隙。
4.3 从事件循环看大量 setState 的批量更新
前端框架(如果某个框架内部采用基于调度的批量更新机制)会应用类似微任务/宏任务的思路。调用栈完全清空之后再统一更新,是为了避免同一轮事件循环里多次重复渲染。框架的这一设计和事件循环密切相关:先把更新标记收集起来,等待当前执行栈清空,再批量进入渲染流程。这本质上是微任务思路的应用。
理解这个,你就能明白为什么"在异步回调里 setState 会合并,在原生事件里 setState 也可能合并"——因为决定合并与否的是当前事件循环的节奏,而不是同步或异步这个简单标签。
5. Node.js 事件循环:六个阶段和浏览器不一样的规矩
5.1 阶段划分:timers、pending callbacks、idle、poll、check、close callbacks
Node 的事件循环不是简单的"一个队列",而是分成了六个阶段。每个阶段有一个自己的任务队列,事件循环按顺序逐个访问:
- timers 阶段:执行
setTimeout和setInterval到期的回调。 - pending callbacks 阶段:执行延迟到下一次循环的 IO 回调(比如 TCP 错误等)。
- idle/prepare 阶段:内部使用,不当回事。
- poll 阶段:核心阶段,处理 IO 事件、获取新的 IO 事件,如果 timers 队列已空、其他队列也空,这里会一直等待。
- check 阶段:执行
setImmediate回调。 - close callbacks 阶段:执行
close事件的回调,比如 socket 关闭。
关键点在于:process.nextTick不在任何一个阶段里,它在每个阶段切换之间都会被执行,而且它的优先级比微任务还高。所以你会看到这样诡异的结果:
setImmediate(() => console.log('immediate')); process.nextTick(() => console.log('nexttick')); Promise.resolve().then(() => console.log('promise'));提示:在 Node 中,process.nextTick先执行,然后promise,最后immediate。原因就是nextTick在当前阶段结束后的"夹缝"里率先跑,Promise 微任务紧随其后,而setImmediate要等到 check 阶段才轮到。
这带来的实际经验是:在 Node 里尽量少用process.nextTick,它虽然很"快",但会把执行顺序搞得特别难预测,而且可能阻塞 IO 轮询。需要把某个回调放到当前同步代码之后执行,优先用Promise.resolve().then或queueMicrotask,需要"尽快但不阻塞"时用setImmediate其实更合适。
5.2 setTimeout(0) 和 setImmediate 的比拼
有一个经典问题:setTimeout(() => {}, 0)和setImmediate(() => {})到底谁先执行?答案是:不一定,取决于进入事件循环的时机和系统状态。
在正常情况下(没有在 IO 回调里),setTimeout(0)的回调会等到 timers 阶段执行,而setImmediate要到 check 阶段。如果这是第一次进入事件循环,timers 阶段先到,所以setTimeout通常先跑。但这只是"通常",因为 timers 阶段如果发现定时器并没有到期(比如 CPU 正在忙别的事情,计时器还没到期),它会跳过,然后 poll 阶段可能等待,最后 check 阶段跑setImmediate——顺序就反了。
但如果在fs.readFile的回调里,setImmediate一定先于setTimeout(0),因为fs回调本身在 poll 阶段执行完毕,接下来紧邻的就是 check 阶段,setImmediate会先触发。这个坑在写测试用例时很容易踩到,所以写"谁先谁后"相关的单测时,尽量不要用setTimeout(0)和setImmediate做对比断言,除非明确知道自己在哪个阶段。
5.3 阻塞事件循环的识别与保命手段
在 Node 服务端,事件循环健康度直接决定了服务的吞吐量。如果你的某个接口路由里写了一段同步的 CPU 密集计算,比如循环几百万次做加密或者 JSON 处理,那么整个进程都会被这段代码独占,所有并发请求全部排队。这就是典型的"事件循环阻塞"。
排查手段我一般这样用:
- 用
process.hrtime()或者console.time粗测接口耗时,确认是不是单次调用就异常慢。 - 把可疑计算放到单独的子进程或 worker_threads 里,避免占满主线程。
- 用 Node 自带的诊断工具看事件循环延迟,比如轮询一个定时器记录实际触发间隔,如果远大于预设值,基本就是被阻塞了。
当然,最好的做法还是从根本上避免在主线程上跑 CPU 密集型任务。像图像处理、大规模数据序列化这种,交给 worker 才能让事件循环保持轻快。
6. 用事件循环知识解决实际问题的排错思路
6.1 定位一个"偶发性顺序错乱"的 bug
有一次我遇到一个诡异问题:页面上有个请求A,它在Promise里依赖另一个请求B的返回值,但偶尔B还没返回,A就已经执行了。业务方排查了半天找不到原因,觉得是并发库的问题。我把代码一看,发现有人把B的启动写在了await A()之后,再用then链把结果传给某个公共状态。但A()本身是一个"立即拿到 Promise"的长轮询,它的回调并没有等待B,而是直接读了公共状态里的旧值。
这个问题的根子就在:事件循环里,微任务和宏任务的执行顺序决定了"异步代码在哪个时间点能读到数据"。如果用 Promise 包装B的赋值操作,但A是在B之前的宏任务里直接读取变量,那一瞬间读取到的当然是旧值。调整方式是用await把依赖项串起来,或者把状态读取也放到微任务链路里,保证读取发生在写入之后。
这类 bug 之所以难查,是因为它不报错,只是偶发。偶发的原因是:到底哪个任务先完成,取决于网络返回速度和队列状态,而不是代码书写顺序。
6.2 事件循环视角下的性能优化清单
结合我自己的经验,以下问题如果在性能排查中出现,优先考虑是不是事件循环节奏出了问题:
- 页面滚动时有明显的卡顿,但单独跑每个插件的性能都不差:可能是某个监听回调里做了重计算,占满了主线程,或者回调注册频率太高,一帧内执行了多次。
- 某个异步任务"莫名延迟"了数百毫秒:看看是不是前面有大量的微任务排队(比如很多
.then链),又或者宏任务队列里积压了大任务。 - 发请求后 UI 一直不能更新:请求回调里调用了大量同步操作,阻塞了渲染时机。
优化的通用思路是:把大任务拆小、把非关键异步降级到宏任务、把关键异步保持在微任务。举个例子,如果你有一段for循环 10 万次的数据处理,但是每 1000 次就可以让出一帧,你用setTimeout分片处理会比一次全跑完流畅很多:
let i = 0; const arr = new Array(100000).fill(0).map((_, k) => k); function processChunk() { const end = Math.min(i + 1000, arr.length); for (; i < end; i++) { // 处理 arr[i] } if (i < arr.length) { setTimeout(processChunk, 0); } } processChunk();这里每次分片结束后用setTimeout让出主线程,浏览器就能在宏任务间隙绘制新帧,用户的感知就从"页面卡死几秒"变成"稍等片刻渐入佳境"。
6.3 利用微任务实现"状态合并"
另一个实用技巧是:利用微任务的执行时机,把多个同步修改合并成一次回掉。比如你有一个监听器,要响应用户在同一帧内的多次改变,但不想每次改变都触发一次onChange。可以做一个 debounce,但把它放在微任务而不是宏任务里:
let scheduled = false; let pendingValue; function setValue(v) { pendingValue = v; if (!scheduled) { scheduled = true; Promise.resolve().then(() => { scheduled = false; onChange(pendingValue); }); } }这样setValue在同一个调用栈内连续调用多次,实际只会触发一次onChange,而且不会像setTimeout那样产生额外的帧延迟。这个思路在很多状态管理中都能看到影子。
7. 那些年我们都误解过的"异步神器"
7.1 setTimeout(0) 没有那么神
坊间流传的"用setTimeout(0)可以把耗时操作挪到后面执行"其实只说对了一半。setTimeout(0)只是把回调放到宏任务队列末尾,它并不会把任务"卸载"到别的线程。如果当前调用栈里有一个大循环没跑完,setTimeout(0)的回调一样得排队等。用它的"让出"作用是给浏览器一个喘息的机会,仅此而已。
如果你想让任务真正并行,靠的其实是 Web Worker、worker_threads 或者底层的 IO 线程,而不是事件循环本身。
7.2 Promise 是"异步"但创建瞬间是同步的
这是个很常见的混淆点。new Promise(executor)中的 executor 函数是同步执行的。也就是说:
const p = new Promise((resolve) => { console.log('inside executor'); resolve(); }); console.log('after promise');这里的输出是先打印inside executor,再打印after promise。而then里的回调才是异步微任务。很多初学者以为"Promise 的一切都是异步的",结果诊断半天发现executor里的日志早于后面的同步代码打印,还以为哪里出了问题。
7.3 async 函数的返回值会再包一层 Promise
async function的返回值一定是一个 Promise,即使你 return 一个常量。所以如果你写了const result = await asyncFunc(),那拿到的才是实际值;如果不加await,拿到的是一个 Promise 对象。这在链式调用中容易引发"Promise 嵌套"的误解:如果 async 函数返回另一个 Promise,外层会吸收它,不会产生一层再包一层的情况,但执行时机上的微任务排队次数会有差异。
我在代码评审时经常看到同事写:
async function load() { const data = await fetchData(); setState(data); }或者在某个路由处漏了 await,导致后面的逻辑拿到的不是真实数据而是 Promise,从而产生"偶发 undefined"。这些问题的本质,都是没有从事件循环和微任务机制去理解 async/await 的暂停、恢复和排队规则。
8. 写在最后:当事件循环被真正吃透之后
我见过不少同学学事件循环是为了过面试,背完微任务宏任务顺序就放下了。但真正在工作中吃透它的好处是:排查问题时有方向,而不是一个个加console.log碰运气。遇到性能问题,你脑子里会自动浮现"这一段是不是堵了调用栈""这个递归是不是变成了微任务风暴""这里是不是应该让出主线程",而不是等用户反馈页面卡了才开始怀疑框架。
最后分享一个我个人很喜欢的小实验:在浏览器里跑一段同步大循环,然后同时打开一个简单的 CSS 动画,观察动画是否流畅。多跑几次,你就能直观感受到"调用栈占用如何影响渲染"这个抽象概念。然后再把大循环改成setTimeout分片,再观察一次,体会一下事件循环让出机制的威力。这种直观的体验,比背一百遍理论都管用。
事件循环不是一个需要"背下来"的知识点,而是一套帮助你理解 JavaScript 异步世界的操作系统。把它的脾气摸透了,你写的代码会自然变得更加稳重,排查线上问题的速度快到让人怀疑你是不是开了什么外挂。愿你也能早日摸透它。