☰
深入理解JavaScript事件循环:宏任务、微任务与任务队列全解析
2026/10/6 14:09:56 网站建设 项目流程

前两天在技术群里看到有人贴了这么一段代码:

setTimeout(() => console.log('setTimeout'), 0); Promise.resolve().then(() => console.log('promise')); console.log('同步代码');

问大家输出顺序是什么。结果评论区瞬间吵起来了,有人说先打印 promise,有人说先打印 setTimeout。最后跑了一遍,控制台输出是"同步代码"在最前,接着是"promise",最后才是"setTimeout"。

这背后就是JavaScript的"任务队列、宏任务、微任务"在起作用。很多人学了Promise、setTimeout,能写出能用,但一旦被问到底层排队机制就开始含糊。如果你也卡在"为什么微任务总在宏任务前面"、"Promise.then到底进了哪个队列"、"Flutter里Future.then是否也是微任务"这类问题上,这篇就是为你准备的。

我不打算给你背MDN文档,而是把事件循环这套东西拆开揉碎,用我实际调试和踩坑的经验讲清楚:任务队列是怎么运作的、宏任务和微任务到底怎么区分、哪些API归哪边、以及怎么在浏览器和Flutter里验证你的判断。

1. 为什么单线程的JS需要一套"排队机制"——任务队列的诞生逻辑

要搞懂宏任务和微任务,得先理解JavaScript的运行环境。JS从设计之初就是单线程语言,主线程同一时间只能干一件事。这个特性让DOM操作不会出现竞态,但也带来一个致命问题:如果某个操作特别耗时,后续代码全被堵死。

1.1 从"同步阻塞"到"异步回调":调用栈的工作方式

我们写的每一行代码都会进入调用栈(Call Stack)执行。栈是后进先出的结构,函数调用时压栈,函数返回时出栈。比如这段代码:

function a() { console.log('a'); b(); } function b() { console.log('b'); } a();

执行顺序是:先压入a,a内部打印后压入b,b打印完出栈,最后a出栈。这个过程非常直观,没有任何排队。

问题出在setTimeout这类API上。你写setTimeout(fn, 0),意思是"0毫秒后执行fn",但浏览器不可能提前知道fn什么时候该执行,它得有个东西先接着这个任务,到了时间再通知JS引擎。这个"接着"的地方,就是任务队列(Task Queue)。JS引擎主线程执行完当前调用栈后,会去任务队列里取下一个任务来执行。所以任务队列本质上是一个缓冲地带,让异步操作的结果能被"排上号"。

1.2 渲染线程、定时器线程与"排队"的真实场景

很多初学者以为浏览器只有一个线程在做所有事,其实主线程只是执行JS的那条,浏览器背后还有渲染线程、定时器线程、网络请求线程等一堆"工人"在干活。setTimeout的计时就是由定时器线程负责的,网络请求由网络线程负责。这些工人完成任务后,把结果当成一个任务丢进任务队列,主线程空闲了就来取。

这种"多线程干活、单线程执行结果"的模式,就是事件循环(Event Loop)的真实面貌。事件循环可以理解为一个永不停止的循环:

  1. 检查调用栈是否为空
  2. 栈为空,就去任务队列取出最前面的任务
  3. 执行该任务(即调用这个任务对应的回调函数)
  4. 执行完再回第1步

这套机制保证了异步代码不会突然打断正在运行的同步代码,所有代码都在"排队"的秩序下执行。但只有一套队列不够——浏览器后来发现,有些任务需要"更紧急"地被执行,微任务就这样登场了。

2. 宏任务与微任务的"排队规矩":微任务队列凭什么插队

"宏任务"和"微任务"这两个词听起来像在分大小,实际上它们的核心差异在于入队的地方不同,以及被取出的时机不同。宏任务进入宏任务队列(Task Queue),微任务进入微任务队列(Microtask Queue)。两个队列不是平级并排的,而是有优先级。

2.1 两套队列,两种调度:一轮事件循环的完整流程

要理解微任务的"插队"能力,得记住事件循环的一个关键规则:每执行完一个宏任务,都会立刻清空整个微任务队列。完整的一次循环是这么走的:

  1. 从宏任务队列中取出一个宏任务执行
  2. 执行完毕后,检查微任务队列,把里面的微任务全部取出来执行
  3. 微任务队列清空后,再做浏览器渲染相关的操作(如果有的话)
  4. 回到第1步,取下一个宏任务

注意"全部"这个词。宏任务是一个一个取的,但微任务是一批一批清的。只要微任务队列里有新任务进来,就会一直被执行,直到队列彻底空了,主线程才喘口气回去取下一个宏任务。

那么"宏任务"和"微任务"具体是什么呢?你可以这样理解:宏任务是由宿主环境(浏览器、Node.js)发起的任务,微任务是由JavaScript自身发起的任务。比如setTimeout的回调是浏览器定时器线程丢进来的宏任务;而Promise.then的回调,是JS引擎在Promise状态变更时自己产生的微任务。

2.2 "插队"机制的最佳类比:排队取餐和店员喊号

把这两个队列放到生活里看就简单了。宏任务队列像餐厅的取餐队列,顾客按号排队;微任务队列则像一个"优先窗口",服务员喊到你的号,你取完餐就得立刻让下一个优先号来,而且只要优先窗口有人,普通取餐队列就一直等着。

具体到代码里,两件事同时发生才会有"插队"效果:

setTimeout(() => console.log('我是宏任务')); Promise.resolve().then(() => console.log('我是微任务'));

输出永远是"我是微任务"先打印。因为setTimeout的回调被丢进宏任务队列,Promise.then的回调被丢进微任务队列。主线程执行完当前同步代码后,发现微任务队列里有活,立刻清空;清完了才去宏任务队列取setTimeout。这个顺序非常稳定,跟setTimeout写的延迟毫秒数无关(只要不小于最小阈值),微任务总能排在前面。

3. 一张表认清所有异步API的"归属":别再凭感觉归类

知道原理还不够,关键要能认出哪些API会生成宏任务、哪些生成微任务。这里最容易出问题,因为不少API看起来像异步,但实际进的是完全不同队列。

3.1 宏任务队列成员:定时器、I/O、UI渲染、事件回调

宏任务家族最核心的成员如下:

API场景说明
setTimeout / setInterval定时执行延迟时间不是精确保证,跟队列拥堵有关
setImmediate(Node.js)本轮事件循环结尾执行浏览器没有这个API
I/O操作文件读写、网络请求完成回调Node.js里最常接触的宏任务来源
UI交互事件点击、滚动等事件回调事件回调本身就是宏任务
requestAnimationFrame下一帧渲染前执行比较特殊,它和渲染时机强相关
MessageChannel跨上下文通信可以手动创建宏任务

很多人会问:"那fs.readFile的回调是宏任务吗?"在Node.js里,I/O回调确实是宏任务。也就是说,如果你同时发起一个文件读取和一个Promise.then,Promise的微任务总是先执行,哪怕文件读取其实已经完成了。

3.2 微任务队列成员:Promise.then、queueMicrotask、MutationObserver

微任务家族成员相对少,但个个重要:

API场景说明
Promise.then / catch / finallyPromise状态变更后的回调异步编程最常用的微任务来源
async函数中的await后续代码await后面紧接的代码就是微任务本质上是Promise的语法糖
queueMicrotask手动创建微任务浏览器原生API,Node.js同样支持
MutationObserverDOM节点变化回调常被忽略,但它确实是microtask
process.nextTick(Node.js)当前操作结束后立刻执行严格说它比Promise的微任务优先级更高

3.3 容易被归错队的"隐性异步":事件回调、async/await、render

我自己踩过不少坑,可以分为三类:

第一类是事件回调。很多人以为element.addEventListener('click', fn)里面的fn是微任务。不是,它是宏任务。事件触发时浏览器把回调排进宏任务队列,所以你在点击事件里加一个Promise.then,微任务会先跑。

第二类是async/await。await后面那段代码什么时候执行?很多人背结论说"await之后的代码是微任务",但容易搞混。更准确的说法是:await会把后续代码包装成Promise的then回调。举个例子:

async function demo() { console.log('a'); await Promise.resolve(); console.log('b'); } console.log('c'); demo(); console.log('d');

输出顺序是c、a、d、b。为什么?因为demo()先执行到await这一行,console.log('a')是同步执行的;执行到await时,右侧的Promise.resolve()已经完成,函数让出执行权,后面的console.log('b')会被排进微任务队列。主线程继续执行console.log('d'),最后微任务队列清空时打印b。

第三类是渲染时机。requestAnimationFrame虽然看起来像宏任务,但它跟setTimeout有个本质区别:它的执行时机是在渲染之前,而且浏览器会保证在下一帧渲染前执行它。如果你把setTimeout(fn, 0)塞在循环里,浏览器可能来不及渲染就执行完所有任务,页面直接卡住。这个坑在动画和大量DOM操作场景非常典型。

4. 用一个经典代码逐行推演事件循环:从输出顺序到执行时序

光说不练假把式。我们拿一段代码做一次完整的"人肉事件循环",把每一步走给你看。这种推演能力在面试里是硬通货,更是排查线上异步问题的基础功。

4.1 经典五连问:一段代码跑出什么顺序

先看这段:

console.log('script start'); setTimeout(function () { console.log('setTimeout'); }, 0); Promise.resolve() .then(function () { console.log('promise1'); }) .then(function () { console.log('promise2'); }); console.log('script end');

第一步,同步代码先执行,打印"script start"。

第二步,遇到setTimeout,把回调放进宏任务队列,这个回调暂时没有编号,我们记为宏任务A。

第三步,遇到Promise.resolve().then(...)。Promise对象已经处于resolved状态,第一个then回调被放进微任务队列,记为微任务B。第二个then回调现在还没入队,因为要等第一个then回调执行完且返回值处理后,Promise链才会继续注册第二个then。这是很容易忽略的细节。

第四步,执行console.log('script end'),打印。

到这一步,调用栈空了。事件循环开始工作:

第五步,清空微任务队列。取出微任务B,执行,打印"promise1"。B执行完后,Promise链返回的是undefined,但因为是resolved状态,第二个then回调被注册并放进微任务队列,记为微任务C。

第六步,微任务队列还没空,继续取C,执行,打印"promise2"。

第七步,微任务队列空了,事件循环从宏任务队列取出宏任务A,执行,打印"setTimeout"。

最终输出:

script start script end promise1 promise2 setTimeout

这个推演里最值得注意的地方是:第二个then是在第一个then执行过程中才入队的。如果你在第一个then里再加一个微任务,那这个微任务会排在promise2前面。微任务队列的"全部清空"机制意味着,你在微任务里不断生成新微任务,宏任务永远轮不到。

4.2 在生产环境遇见的真实案例:微任务无限循环导致页面白屏

我遇到过不止一次"页面白屏、script卡死"的线上故障,最后定位到微任务搞的鬼。简化后的代码长这样:

function recursionMicrotask() { Promise.resolve().then(() => { // 一些业务逻辑 recursionMicrotask(); // 用户留下的诡异递归 }); } recursionMicrotask();

这段代码理论上会无限往微任务队列里塞任务。因为微任务队列清空是"执行一个、检查一个",每执行一个微任务,又产生一个新的微任务,微任务队列永远清不空,事件循环永远走不到取宏任务那一步。渲染线程自然也被卡死,页面白屏。

这个案例告诉我们两个教训。第一,不要在微任务里无脑递归,如果确实需要重复调度,用setTimeout把它变成宏任务,至少每次宏任务之间还能喘口气、处理一下渲染。第二,线上排查这类问题,第一反应是打开DevTools的Performance面板,看主线程的时间线是不是被微任务占满了。

顺带说一个我在开发中养成的习惯:每次写Promise链式调用,如果发现微任务里还要再跳一个微任务,就停一下,想想是不是该用await或者直接合并逻辑。微任务确实快,但过量使用会让宏任务和渲染长周期饥饿,最终影响体验。

5. Node.js里的任务队列:多了一个nextTick大佬

浏览器说完,Node.js环境的队列规则也有不少差异,尤其多了一个process.nextTick,优先级比Promise的微任务还高,这个知识点如果你打算搞服务端,绕不开。

5.1 Node.js的事件循环阶段与"每个阶段专属队列"

Node.js的宏任务队列不是一条,而是按阶段分成了好几条。完整的事件循环按顺序执行这些阶段:timers、pending callbacks、idle/prepare、poll、check、close callbacks。每个阶段都有自己的队列:

阶段处理什么关键API
timerssetTimeout和setInterval回调setTimeout
pending callbacks上一轮残留的I/O回调部分系统错误回调
poll大部分I/O回调文件读取、网络请求等
checksetImmediate回调setImmediate
close callbacks关闭事件的回调socket.on('close')

每个阶段执行完后,Node.js会优先清空微任务队列,再清空nextTick队列(实际顺序是nextTick先于微任务,下面细说),然后才进入下一个阶段。这个设计跟浏览器有本质区别:浏览器是"取一个宏任务,清空一次微任务",Node.js是"执行完一个阶段的所有任务,再清空微任务"。

5.2 process.nextTick:比Promise.then更"急"的回调

process.nextTick在Node.js里是个特殊存在。它的回调不进入微任务队列,而是进入专门的nextTick队列,而且在每一轮事件循环阶段切换时,Node.js会优先把nextTick队列全部清空,然后再清空微任务队列。所以如果你写:

process.nextTick(() => console.log('nextTick')); Promise.resolve().then(() => console.log('promise')); setTimeout(() => console.log('setTimeout'), 0);

输出顺序是nextTick、promise、setTimeout。很多刚开始写Node的同学被这个顺序坑过,尤其在封装回调风格的库时,总以为Promise先执行,结果nextTick横插一杠。

这里给你一个实用建议:生产代码里尽量别用process.nextTick,能用setImmediate就用setImmediate。因为nextTick的优先级太高了,容易让I/O操作被无限延迟,出现"nextTick递归导致poll阶段饿死"的问题。Node官方文档甚至专门警告过这个过程。理解了为什么有nextTick大概率会出问题,你才算真正懂Node的队列系统。

5.3 浏览器与Node.js的任务队列差异对照

做一套对照表,方便你日常开发和面试时直接查:

差异点浏览器Node.js
宏任务队列单队列按阶段分多条队列
微任务清空时机每个宏任务执行完后每个阶段结束后
特殊优先队列无nextTick队列
渲染时机微任务清空后、下一个宏任务之前无渲染线程概念

有一个经典面试题是:

setImmediate(() => console.log('setImmediate')); setTimeout(() => console.log('setTimeout'), 0);

问输出顺序。答案是不一定。如果主模块在poll阶段启动,timers阶段已经过了,那么setImmediate先执行;如果主模块还在timers阶段之前,那么setTimeout先执行。这就是Node事件循环阶段化的典型表现。你只要记住"Node宏任务分阶段执行"这个前提,就不难理解为什么这类题目没有固定答案。

6. 延伸热点:Flutter里Future.then的回调会进微任务队列吗

开头提到的那条热搜词:"Flutter future的then回调是放入微任务队列吗",这是很多从JavaScript转Dart的开发者的头号疑问。我先给结论:是的,Dart中Future.then注册的回调会被放入微任务队列(Microtask Queue),在事件循环的微任务清空阶段执行。但这里有几个细节跟JavaScript不一样,值得展开。

6.1 Dart的单线程模型:事件循环 + 两条队列

Dart和JavaScript一样是单线程执行模型,但它的事件循环有两条核心队列:事件队列(Event Queue)和微任务队列(Microtask Queue)。事件队列对应JavaScript的宏任务队列,持有外部事件、计时器、I/O结果等回调;微任务队列持有内部产生的短任务。

Dart的微任务队列清空时机跟浏览器类似:在当前同步代码执行完毕后、下一个事件(宏任务)被处理之前,Dart会先把微任务队列全部清空。所以下面的代码:

import 'dart:async'; void main() { Future(() => print('event')); // 事件队列任务 Future.microtask(() => print('microtask')); // 微任务 scheduleMicrotask(() => print('microtask2')); print('sync'); }

输出顺序是sync、microtask、microtask2、event。因为Future(() => ...)默认把回调放入事件队列,Future.microtask和scheduleMicrotask则明确放入微任务队列。

6.2 Future.then回调的入队细节与验证方式

Future.then的情况跟Promise.then有点像,但又多了一层:如果Future已经完成,then回调会被调度为微任务;如果Future还没完成,then回调要等Future完成后再进入微任务队列。换个角度理解:

Future<String> fetchData() async { await Future.delayed(Duration(seconds: 1)); return 'done'; } void main() { fetchData().then((value) { print('then: $value'); }); print('main end'); }

main end先打印,一秒钟后then: done打印。这个then回调不是立刻入队的,而是Future完成之后才入队。所以"一定是微任务"这个说法不够精确——准确的表述是"then回调最终以微任务的形式执行,但入队时间取决于Future何时完成"。

在Dart里有一个很实用的小技巧来验证微任务队列的存在:用runZoned加上Zone的钩子观察调度。比如:

runZoned(() { Future.value(1).then((v) => print('then value: $v')); scheduleMicrotask(() => print('microtask')); }, zoneSpecification: ZoneSpecification( createTimer: (self, parent, duration, f) { print('create timer: $duration'); return parent.createTimer(duration, f); }, ));

如果你看到then回调先于普通事件队列打印,就能确认它的微任务身份。实际跑Flutter应用时,还能利用DevTools的Timeline面板跟踪微任务执行情况,不过用命令行Dart写个小demo验证更直接。

6.3 JS的Promise.then与Dart的Future.then:两个易混概念

这里做一个对比,帮同时搞两者的开发者理清思路:

对比项JavaScript Promise.thenDart Future.then
默认入队微任务队列微任务队列
前端任务已完成时立即作为微任务入队任务完成后作为微任务入队
补充调度方式queueMicrotaskFuture.microtask / scheduleMicrotask
与async/await关系await是Promise语法糖await是Future语法糖

在Flutter里经常遇到的一个场景是:你在build方法里不能直接做耗时操作,要用Future包装。如果你不小心在UI线程创建了大量微任务,同样会遇到帧渲染被阻塞的问题——因为UI帧的调度和微任务清空之间也有优先级博弈。正确做法是,把重任务放到compute或Isolate里,避免在微任务队列里堆太多活。这也是Dart中"微任务只放轻量逻辑"的原因所在。

7. 把任务队列玩明白的几条实操建议

写到这里,该收尾了。聊点我自己的体会和建议,全是这几年在各种异步场景里磨出来的经验。

第一,判断一段代码的异步行为,永远先定位"入队时机",再判断"执行顺序"。太多人死记硬背"Promise比setTimeout快",但遇到await后面的代码就傻眼。其实只要记住,await只是把后续代码注册成微任务,而注册发生在await这一行被执行的瞬间,很多顺序推导就迎刃而解。

第二,遇到"看似随机"的输出顺序,先在脑子里过一遍事件循环的四步。调用栈里同步代码执行完没有?微任务队列清空了吗?宏任务队列里排了几个任务?有没有nextTick或者Zone这种特殊优先级?四步走完,大多数异步问题都能定位。

第三,利用DevTools的Sources面板打断点配合Console看微任务执行。Chrome的DevTools可以在Promise回调位置加上断点,然后查看右侧Call Stack和Scope面板,能亲眼看到微任务栈帧。调试的时候比干猜靠谱一百倍。Node.js则可以用--trace-events-enabled参数,在trace文件里直接看到microtask和macrotask的分布。

最后说个冷门细节:setTimeout(fn, 0)在浏览器里其实有最小延迟限制。嵌套层级过深时,最短延迟会变成4毫秒左右。这是为了防抖、降低功耗。所以在极端循环场景下,你看到的宏任务执行顺序可能跟预期不同。这不是队列问题,而是定时器线程本身做的手脚。遇到就查文档,别一头扎进事件循环里走不出来。

任务队列这套机制,难不在概念,难在把"排队规则"和"实际API行为"对应起来。你只要多写几个demo、多推演几段代码,就能慢慢建立起肌肉记忆。这篇里给的验证方法和观察角度,应该能帮你少走不少弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询