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

资讯详情

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

深入理解JavaScript事件循环:宏任务、微任务与任务队列全解析

深入理解JavaScript事件循环:宏任务、微任务与任务队列全解析 前两天在技术群里看到有人贴了这么一段代码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();执行顺序是先压入aa内部打印后压入bb打印完出栈最后a出栈。这个过程非常直观没有任何排队。问题出在setTimeout这类API上。你写setTimeout(fn, 0)意思是0毫秒后执行fn但浏览器不可能提前知道fn什么时候该执行它得有个东西先接着这个任务到了时间再通知JS引擎。这个接着的地方就是任务队列Task Queue。JS引擎主线程执行完当前调用栈后会去任务队列里取下一个任务来执行。所以任务队列本质上是一个缓冲地带让异步操作的结果能被排上号。1.2 渲染线程、定时器线程与排队的真实场景很多初学者以为浏览器只有一个线程在做所有事其实主线程只是执行JS的那条浏览器背后还有渲染线程、定时器线程、网络请求线程等一堆工人在干活。setTimeout的计时就是由定时器线程负责的网络请求由网络线程负责。这些工人完成任务后把结果当成一个任务丢进任务队列主线程空闲了就来取。这种多线程干活、单线程执行结果的模式就是事件循环Event Loop的真实面貌。事件循环可以理解为一个永不停止的循环检查调用栈是否为空栈为空就去任务队列取出最前面的任务执行该任务即调用这个任务对应的回调函数执行完再回第1步这套机制保证了异步代码不会突然打断正在运行的同步代码所有代码都在排队的秩序下执行。但只有一套队列不够——浏览器后来发现有些任务需要更紧急地被执行微任务就这样登场了。2. 宏任务与微任务的排队规矩微任务队列凭什么插队宏任务和微任务这两个词听起来像在分大小实际上它们的核心差异在于入队的地方不同以及被取出的时机不同。宏任务进入宏任务队列Task Queue微任务进入微任务队列Microtask Queue。两个队列不是平级并排的而是有优先级。2.1 两套队列两种调度一轮事件循环的完整流程要理解微任务的插队能力得记住事件循环的一个关键规则每执行完一个宏任务都会立刻清空整个微任务队列。完整的一次循环是这么走的从宏任务队列中取出一个宏任务执行执行完毕后检查微任务队列把里面的微任务全部取出来执行微任务队列清空后再做浏览器渲染相关的操作如果有的话回到第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定时执行延迟时间不是精确保证跟队列拥堵有关setImmediateNode.js本轮事件循环结尾执行浏览器没有这个APII/O操作文件读写、网络请求完成回调Node.js里最常接触的宏任务来源UI交互事件点击、滚动等事件回调事件回调本身就是宏任务requestAnimationFrame下一帧渲染前执行比较特殊它和渲染时机强相关MessageChannel跨上下文通信可以手动创建宏任务很多人会问那fs.readFile的回调是宏任务吗在Node.js里I/O回调确实是宏任务。也就是说如果你同时发起一个文件读取和一个Promise.thenPromise的微任务总是先执行哪怕文件读取其实已经完成了。3.2 微任务队列成员Promise.then、queueMicrotask、MutationObserver微任务家族成员相对少但个个重要API场景说明Promise.then / catch / finallyPromise状态变更后的回调异步编程最常用的微任务来源async函数中的await后续代码await后面紧接的代码就是微任务本质上是Promise的语法糖queueMicrotask手动创建微任务浏览器原生APINode.js同样支持MutationObserverDOM节点变化回调常被忽略但它确实是microtaskprocess.nextTickNode.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。每个阶段都有自己的队列阶段处理什么关键APItimerssetTimeout和setInterval回调setTimeoutpending callbacks上一轮残留的I/O回调部分系统错误回调poll大部分I/O回调文件读取、网络请求等checksetImmediate回调setImmediateclose 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完成后再进入微任务队列。换个角度理解FutureString 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、多推演几段代码就能慢慢建立起肌肉记忆。这篇里给的验证方法和观察角度应该能帮你少走不少弯路。
返回列表