资讯详情

资讯详情

JavaScript单线程事件循环:微任务与宏任务的执行顺序详解

学 JavaScript 的人早晚会被这几个词按在地上摩擦单线程、事件循环、微任务、宏任务。我在带前端团队和面试候选人的时候特别喜欢问一类问题——先输出一行同步代码再挂一个 Promise.then再挂一个 setTimeout问打印顺序。这个问题看着简单实际上能把同步、异步、事件循环、微任务、宏任务全部串起来很多写了两三年业务代码的同学都会在这里翻车。今天这篇我就把这些概念彻底讲透不堆术语用实际执行逻辑和大量示例把 JS 为什么是单线程、异步到底怎么跑、微任务和宏任务谁先谁后一步步拆开。不管你是准备面试还是工作中被“谜之输出顺序”坑过这篇都值得认真看一遍。1. 为什么 JavaScript 必须单线程1.1 一段代码写 100 年浏览器也做不了别的事先从一个最基础的问题开始JS 凭什么只能是单线程其实答案特别朴素——因为它要操作 DOM。想象一下如果 JavaScript 同时有多个线程每个线程都能改页面上同一个按钮的文字那浏览器听谁的线程 A 说“按钮显示红色”线程 B 说“按钮显示蓝色”最终页面上这个按钮到底是红的还是蓝的为了这种一致性浏览器直接做了个最狠的决定DOM 操作只能在一条线程上执行也就是 JavaScript 主线程。如果你去搜 HTML5 的标准上面写得明明白白JavaScript 的执行和 DOM 渲染在同一个线程上它们互斥。所以一个 JS 线程干着活的时候页面就没法同时渲染反过来页面渲染的时候JS 代码也得在那儿等着。一个非常直观的例子你在控制台里执行while(true) {}整个标签页立刻卡死连鼠标滚动都动不了。因为你把主线程死死占住了渲染进程分不到时间去处理滚动事件、重绘页面、响应点击浏览器唯一能做的就是干瞪眼。有人可能会问那这不就是单线程的缺点吗确实是但这也是 JS 历史包袱加上实际需求权衡出来的结果。后面学的异步编程、事件循环全都是在这个单线程约束下为了让程序“看起来能同时做很多事情”而被设计出来的。1.2 单线程和“同时做事”并不矛盾既然只有一条主线程为什么我们还能同时发请求、监听点击、执行动画答案是你根本不是在“同时执行”而是在“排队调度”。我用生活里的例子解释下单线程的 JS 就像只有一个窗口的银行柜台所有客户来了都得排队。一个客户办完业务下一个人才能上去。但银行不只是有柜台这一个环节还有填单台、叫号机、等待区。客户在柜台办理业务的时候其他客户可以先去填单、取号、坐着等等到柜员喊到号了他们再过来。银行的“事件循环”就是这么设计的主线程就是一个柜员他一次只能服务一个“同步任务”但异步任务不会在柜台前死等而是去后台排队到了时间点再回来。所以“单线程”指的是主线程只有一条而“异步”的本质是把耗时操作交给浏览器或 Node 的底层能力比如网络请求、定时器、文件读取等这些操作有了结果再把回调放进任务队列等主线程空闲了再来取。这种方式计算机领域叫事件驱动。JS 单线程不是不能等而是学会了“不傻等”。2. 同步与异步先把执行顺序掰扯清楚2.1 同步代码的“一条道走到黑”所谓同步执行就是按照代码书写的顺序从上往下逐行执行上一行没跑完下一行绝对不开始。这是 JS 最基础的执行方式也最容易理解。function second() { console.log(第二个); } console.log(第一个); second(); console.log(第三个);这段代码的输出一定是“第一个、第二个、第三个”没有任何悬念。原因在于浏览器执行 JS 时会维护一个“调用栈”也就是 Call Stack。它像一个堆放要执行任务的盒子每调用一个函数就往盒子里压一个“执行上下文”函数执行完了就把这个上下文弹出盒子。栈里面的代码不执行完栈外面的代码永远没有机会运行。我见过很多新手理解不了“同步”到底卡在哪里。举个例子你在第二行放一个死循环console.log(第一个); while (true) {}这段代码会只打印出“第一个”然后页面直接卡死后面的代码永远跑不到。不是浏览器坏掉了而是主线程被 while 死死占据调用栈辛辛苦苦执行了十万次循环仍然没有退出之后的代码自然全部排队等待。这就是同步阻塞。2.2 异步的真正面目不是同时而是稍后理解了同步阻塞异步就好说了。异步代码不会阻塞主线程而是把任务交给宿主环境浏览器或 Node自己在主线程上继续往下走等宿主环境那边处理完再把回调送回任务队列排队。看个最经典的例子console.log(start); setTimeout(() { console.log(timeout); }, 0); console.log(end);输出顺序是什么start、end、timeout。很多人第一次看到这个结果会怀疑setTimeout 时间不是 0 吗为什么 setTimeout 里面的输出反而排到最后原因就是 setTimeout 是异步 API它只是告诉浏览器“0 毫秒后把回调扔进任务队列”并不是“0 毫秒后立刻执行”。主线程要把当前这一段同步代码全部跑完才会去任务队列取这个回调来执行。用银行来类比的话同步代码是“你站在柜台前面得把这笔业务办完才能走”异步代码是“你先去填单然后把单子递给柜员柜员处理好了再叫你”。填单、递单本身不占柜台你还能顺便干别的事。2.3 异步 API 的实际分类JavaScript 里的异步来源非常多但作用逻辑都一样注册回调稍后执行。常见的有这些类型典型 API说明定时器setTimeout / setInterval时间到了把回调放入任务队列网络请求fetch / XHR / axios数据返回后触发回调事件监听element.addEventListener用户操作发生时触发回调微任务Promise.then / queueMicrotask当前同步代码结束后优先处理渲染调度requestAnimationFrame每次浏览器渲染之前执行Node 特有fs.readFile / process.nextTick文件IO完成或下一个阶段前处理这些 API 的共通点在于它们都不会阻塞当前的同步代码。像fetch发请求的时候主线程完全不用等网络响应可以继续渲染页面、处理点击、执行其他逻辑等数据真正回来后再把 callback或者 await 后面的代码扔进任务队列。这种“不等结果”的设计就是 JS 能保持高响应性的根本原因。2.4 从回调地狱到 Promise 再到 async/await既然异步是“稍后执行”那我怎么拿到异步的结果呢最早期的方式是回调函数如fs.readFile(path, callback)、ajax(url, callback)。回调写多了以后有个著名问题——回调地狱getUser(id, (user) { getOrders(user.id, (orders) { getDetails(orders[0].id, (details) { console.log(details); }); }); });这种嵌套不仅难看而且错误处理非常麻烦。于是 Promise 被推出来了。Promise 本质上就是一个状态机把“异步结果”包装成一个对象然后通过.then()来挂载回调通过.catch()处理异常搭配Promise.all、Promise.race可以组合多个异步任务。到了 ES2017 又有了async/await它其实是 Promise 的语法糖可以让异步代码看起来像同步代码async function load() { const user await getUser(id); const orders await getOrders(user.id); const details await getDetails(orders[0].id); console.log(details); }我在这里要特别提醒一个坑await虽然让代码看起来是“一行一行等”但它并没有把 JS 变成同步语言只是把你的后续代码包装成了一个 Promise 回调。后面讲事件循环的时候你会更清楚地看到这一点。3. 事件循环真正决定代码顺序的调度系统3.1 浏览器里的呼叫中心长什么样要彻底理解执行顺序光知道“同步先执行、异步稍后执行”是不够的因为异步和异步之间还有差异。我之前见过一个错误的印象既然 setTimeout 是异步Promise 也是异步那它们不就是一起排队吗如果只是这样的话问题就简单了。但实际情况是异步任务被分成了两类宏任务Macro Task和微任务Micro Task。它们被放进了不同的队列执行优先级完全不同。这里我先把事件循环的整体流程写出来你记住这个顺序后面的题目就都能推导执行全局同步代码调用栈里的任务把同步代码执行过程中遇到的异步回调分别放进宏任务队列或微任务队列同步代码全部执行完调用栈腾空先清空微任务队列里的所有任务微任务全部清空后从宏任务队列里取出一个任务执行执行完这个宏任务后再回头清空微任务队列浏览器可能进行一次渲染回到第 5 步不断循环。简而言之事件循环的精髓是同步代码优先同步结束后清空微任务然后每执行一个宏任务就重新清空一次微任务。注意第 5 步说的是“取出一个宏任务”不是“取出所有宏任务”。这是很多人内存里模糊的地方。3.2 微任务和宏任务到底包含哪些东西到底什么属于微任务什么属于宏任务很多人背不下来其实不用硬背你只需要记住常用的几个来源。微任务队列里的任务通常来自来源示例PromisePromise.resolve().then(cb)、await之后的代码浏览器 APIqueueMicrotask(cb)、MutationObserverNode 环境process.nextTick(cb)注意优先级还更高宏任务队列里的任务通常来自来源示例定时器setTimeout、setIntervalI/O 操作fetch 完成回调、文件读取回调事件回调click、keydown、scroll等用户事件其他postMessage、setImmediate微任务的优先级宏观上高于宏任务。就像是银行等待区里面又分了一个“优先贵宾席”只要贵宾席里有人柜员清完同步柜台上的客户后一定先把贵宾席的人全部叫完才会叫普通排队的客户。而且这个优先级还是“强制连续服务”——每次事件循环抓到一次宏任务机会前必须先让微任务队列完全为空。3.3 一个经典例子把流程走一遍只看概念容易晕我们拿个最短的题目来推演setTimeout(() { console.log(1); }, 0); Promise.resolve().then(() { console.log(2); }); console.log(3);步骤如下开始执行全局同步代码第一行遇到setTimeout这是宏任务浏览器会启动一个定时器时间到后把回调放入宏任务队列。这里定时器只有 0ms所以很快在代码往下跑的时候这个宏任务已经排好队了但还不会执行。继续往下遇到Promise.resolve().then(...)。Promise.resolve()会立刻返回一个已完成的 Promise它的.then回调是微任务直接进入微任务队列。执行console.log(3)输出 3。全局同步代码结束调用栈空了。事件循环开始清空微任务队列队列里只有一个任务执行它输出 2。微任务队列清空再到宏任务队列中取出第一个执行输出 1。所以最终输出是3 → 2 → 1。这里最反直觉的地方就是setTimeout明明最先写却最后执行。它就是被“宏任务排在微任务后面”这一点给压制的。你可以在浏览器控制台里跑一下十次有十次都是 3、2、1。3.4 为什么微任务要设计成“优先调度”可能有人会问既然都是异步为什么不直接按先进先出的顺序排队非要分个微任务宏任务这个设计是有实际意义的。微任务主要服务于“需要尽快执行的后续操作”。比如 Promise 的.then回调它是依赖上一个 Promise 的最终状态而执行的代表着业务逻辑的延续。如果 Promise 的回调被排在宏任务之后那一个请求回来后的数据渲染就要被别的定时器任务插队用户体验会很糟糕。其次浏览器希望微任务队列能够保证一定的原子性当前同步代码执行完相关微任务立刻清空这样每个宏任务前后页面状态是一致的不会出现某个宏任务执行到一半微任务又改乱了状态。从 Node 端也能看到这种设计的影子。Node 里的process.nextTick是一个高优先级的微任务它甚至会排在 Promise 回调之前。很多底层库用它来保证“异常处理”和“状态回调”的尽快执行。所以微任务优先不是某个团队拍脑袋定的而是整个异步模型稳定运行的基石。4. 微任务与宏任务的执行规则与典型题目实战4.1 async/await 在事件循环里的真实位置很多人知道 async 函数返回 Promise知道 await 后面要等但是一到真正排输出顺序的时候还是会踩async1 start和async1 end的位置掉坑。原因是没有把 async/await 翻译成 Promise 微任务来理解。async函数本身是立即执行的。只要你调用它函数体里await之前的同步代码会立刻跑完等到await那一行发生挂起时await后面的代码才相当于.then回调会被放到微任务队列里。说直白一点await之前是同步的await之后是微任务的。看一个面试高频题async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); async1(); new Promise((resolve) { console.log(promise1); resolve(); }).then(() { console.log(promise2); }); console.log(script end);这道题的输出顺序应该如何分析第一步全局开始执行。async1和async2只是函数声明不执行。打印script start。第二步遇到setTimeout(0)回调进入宏任务队列。第三步调用async1()。进入 async1 函数体第一个同步语句打印async1 start。执行到await async2()注意这里会先调用async2()而 async2 的第一个同步输出是async2所以这里打印async2。拿到 async2 的返回值一个 Promise后await就挂起了async1函数后面的console.log(async1 end)会被包装成微任务等当前同步流程结束后才执行。第四步继续往后走遇到new Promise(...)。Promise 构造函数里的 executor 是同步执行的所以立即打印promise1。然后执行resolve()这个 Promise 状态变成 fulfilled它的.then回调进入微任务队列。第五步执行到最后一行打印script end。此时全局同步代码全部执行完毕。第六步开始清空微任务队列。微任务队列里有两个任务第一个是async1 end第二个是promise2。先进先出所以先打印async1 end再打印promise2。第七步微任务清空取出宏任务队列里的第一个打印setTimeout。最终结果就是script start async1 start async2 promise1 script end async1 end promise2 setTimeout我建议你把这个过程亲手画一遍箭头标清楚每一步产生的任务进了哪个队列画完你对事件循环的理解会上一个台阶。这个答案在很多大厂面试里出现的频率极高关键点就是“await async2() 不会阻塞 async2 内部的输出但会把后续代码推迟到微任务”。4.2 Promise 的 executor 是同步执行的上面的例子里有个细节new Promise((resolve) { console.log(promise1); resolve(); })为什么能在同步阶段打印出promise1因为 Promise 构造函数接收的那个函数是立即执行的。很多人以为 Promise 全异步其实只有.then、.catch、.finally里的回调才会异步构造参数是同步执行的。这是一个频繁出错的点。我举个例子console.log(a); new Promise((resolve) { console.log(b); setTimeout(() { console.log(c); }, 0); resolve(); }).then(() { console.log(d); }); console.log(e);输出是a、b、e、d、c。你看出门道了吗b是在同步阶段打印的因为它就写在 Promise 的 executor 里d是微任务在 e 之后打印c是宏任务里的定时器回调最后才打印。如果把resolve()注释掉那么d永远不会打印因为 Promise 的状态永远不会从 pending 变为 fulfilled.then里的回调根本不会被触发。很多休眠 bug 就是这么来的——你调了某个接口接口数据有问题Promise 一直不 resolve后边的逻辑就永远不执行。4.3 宏任务队列每次只取一个微任务队列每次都清空事件循环有个特别微妙的规定宏任务和微任务虽然都是“队列”但是清空方式完全不同。宏任务每轮事件循环只取一个执行执行完就回到微任务队列检查微任务队列则是在每次执行完一个宏任务或者同步代码后被一次性清空到空为止。举一个嵌套定时器的例子setTimeout(() { console.log(宏任务1); Promise.resolve().then(() { console.log(微任务1); }); }, 0); setTimeout(() { console.log(宏任务2); }, 0);这里有两个setTimeout它们分别在 0ms 后进入宏任务队列。事件循环第一次来到宏任务队列时取出第一个宏任务执行打印宏任务1同时遇到Promise.resolve().then(...)注册了一个微任务。这个宏任务执行完后会立刻清空微任务队列打印微任务1。然后事件循环再回来取第二个宏任务打印宏任务2。输出顺序是宏任务1 → 微任务1 → 宏任务2。如果简单理解“宏任务先来后到一起排队”你会以为输出是“宏任务1、宏任务2、微任务1”。但实际不是这样因为微任务有机会在宏任务执行间隙被插队执行。大家在实际编码的时候如果看到一个宏任务回调里创建了微任务而这个微任务又影响了后续业务就务必要记住这个“插队”的行为。4.4 几种特别容易翻车的边界情况事件循环的坑不止面试题下面的场景在工作中很容易碰到。第一个坑Promise 回调里再放一个 Promise。比如Promise.resolve() .then(() { console.log(1); Promise.resolve().then(() console.log(2)); }) .then(() { console.log(3); });这里输出是 1、2、3。原因是第一个.then的回调执行时里面的微任务会被排到当前微任务队列的队尾而外层 React 链上下文里注册的第二个.then也是在当前微任务队列中排队顺序上“内层注册的微任务”反而可能排在外层下一个.then的前面最终 2 在 3 之前。这种顺序初看很反直觉但在处理复杂异步依赖时非常容易踩中。第二个坑await一个不是 Promise 的值。ES 规范里会对await表达式做 Promise 解析Promise Resolve即使await后面跟的是一个普通数字也会把它转换成“已实现的 Promise”后续代码照样放到微任务队列。所以async function test() { console.log(1); await 2; console.log(3); } console.log(4); test(); console.log(5);输出是 4、1、5、3。3的输出一定会被推迟。第三个坑微任务递归创建导致宏任务饿死。前面说过微任务队列在每次检查时是“清空到空为止”如果微任务里又不断添加新的微任务浏览器就永远没机会执行宏任务。类似function infiniteMicrotask() { Promise.resolve().then(infiniteMicrotask); } setTimeout(() console.log(宏任务永远等不到), 0); infiniteMicrotask();这段代码会把宏任务活活“饿死”页面会卡死或者表现异常。所以在生产环境里尽量别写无界的递归微任务定时器反而还能被事件循环调度到不至于完全饿死其他逻辑。5. 常见问题与排查技巧实录5.1 setTimeout(0) 为什么不是立刻执行严格按照规范setTimeout 有最小延迟时间在浏览器中通常会有 4ms 的钳制clamping在某些场景比如多次嵌套调用后还会更明显。不过即便不考虑这 4mssetTimeout(0) 也不可能立刻执行因为它是宏任务必须等当前同步代码跑完再等微任务队列清空才可能被执行。那什么时候需要刻意用 setTimeout(0) 呢比如你想让一段代码“等事件冒泡和渲染结束后再执行”或者要拆分一个极大的同步计算任务就可以用 setTimeout 把一部分逻辑放到下一轮宏任务。但要注意这也是有代价的它对渲染时机、帧率有影响后面我会专门聊。5.2 页面卡顿与渲染时机的关系单线程下代码和渲染互斥所以一个执行时间过长的同步任务会导致页面白屏、按钮无响应、动画掉帧。这就是大家常说“主线程被阻塞”。查这个问题的常用方式打开 Chrome DevTools 的 Performance 面板录制一段操作看“Main”轨道上是不是有一个超长的黄色任务块。如果能看到那就是某个函数占用了主线程过长时间。解决思路通常是事件循环层面的“时间切片”把一个长任务拆成多个短任务放到多个宏任务里分多次执行或者把一部分计算挪给 Web Worker。比如一段需要处理 10 万条数据的循环如果直接跑会造成页面卡顿可以分段处理const list new Array(100000).fill(0).map((_, i) i); const chunkSize 5000; function processChunk(start) { for (let i start; i Math.min(start chunkSize, list.length); i) { // 处理 list[i] } if (start chunkSize list.length) { setTimeout(() processChunk(start chunkSize), 0); } } processChunk(0);这样每一段循环只占一小段时间每处理完一段就让出主线程浏览器有机会渲染一次或响应一次用户事件页面的卡顿感会明显减少。5.3 微任务和宏任务对性能的隐性影响业务代码里微任务用得太多也不行。微任务虽然优先级高但它也是要占用主线程执行时间的。如果你在一段很短的同步代码里创建了成千上万个 Promise微任务队列会变得很长清空这个队列本身就可能阻塞主线程让下一个宏任务比如页面渲染或用户点击回调被无限推迟。我之前接手过一个项目表格更新逻辑里遍历几百行数据每一行都用 async 函数包了一层然后.then里又去更新 DOM。看起来代码漂亮实际上微任务队列里塞了几百个回调在主线程上排队执行性能反而不如直接在同步循环里更新汇总变量。对于这种场景我建议把可合并的任务合并成一次更新不要在循环里反复创建 Promise更不要让微任务和宏任务互相嵌套太多层。5.4 浏览器和 Node 的事件循环差异事件循环不是浏览器独有的Node.js 也有一套自己的事件循环机制大体相似但细节有差异。最明显的区别是 Node 的异步阶段划分更细比如timers阶段处理setTimeout和setIntervalpoll阶段处理 I/O 事件check阶段处理setImmediateclose callbacks阶段处理关闭事件。另外 Node 里有process.nextTick它的优先级高于普通微任务也就是说process.nextTick回调会在同一个阶段结束后立刻执行甚至在 Promise 回调之前。这里也带来一个经典 Node 面试题setImmediate和setTimeout谁先执行这个答案不确定要看进程启动时机和当前进入事件循环的阶段。如果在主模块里直接跑setImmediate(() console.log(immediate)); setTimeout(() console.log(timeout), 0);结果有可能 timeout 先也有可能 immediate 先取决于外层代码执行完进入事件循环时是否已经过了 timers 的阈值。但在 I/O 回调内部setImmediate通常会先执行因为它所在的check阶段比下一轮timers阶段更早被访问到。如果没有真实场景需要深入先知道“顺序不确定”这一点就足够避免被网上的绝对化结论误导了。5.5 一份自查速查表问题结论同步代码和微任务谁先执行同步代码先执行完再执行微任务微任务和宏任务谁先执行微任务先执行宏任务后执行宏任务队列每次取几个每次只取一个执行完会再检查微任务微任务队列每次清空多少一次性全部清空Promise 构造函数是同步还是异步同步执行executorawait 后面的代码什么时候执行当前同步流程结束后以微任务方式执行setTimeout(0) 最晚还是最早宏任务通常最晚process.nextTick 比 Promise 谁优先在 Node 里 nextTick 优先这张表可以贴在自己的知识库笔记里面试前过一遍比死记硬背一堆概念有用得多。6. 实操心得把事件循环用到真实项目里6.1 用 DevTools 亲眼看到任务队列学了再多理论都不如自己“看见”一次。打开 Chrome DevTools 的 Performance 面板点击录制然后执行一段包含 setTimeout、fetch、滚动事件的页面操作结束录制后你能看到主轨道上分布着一块块不同颜色的小任务。它们就是一个个宏任务和微任务在事件循环中的实际执行片段。把鼠标悬停上去可以查看每个任务调用栈和耗时。还有一个更轻量的验证方式直接用console.log加计数器观察宏任务与微任务的执行顺序变化。比如你想验证“宏任务每执行一次就会清空一次微任务”可以写两个定时器中间塞 Promise 回调实际跑一遍远比只看文档印象深刻。6.2 用微任务和宏任务做“调度优先级”在真实项目中事件循环可以用来做任务优先级设计。如果你想在不阻塞用户交互的前提下优先处理一些实时性高的逻辑可以把它们放进微任务如果是一些不那么紧急、可以稍后处理的批量操作可以塞进定时器或者用requestIdleCallback去安排。举个例子你在监听用户输入时需要把一个值同步到多个 UI 组件同时又要把输入内容做一次昂贵的校验和存储。此时更新 UI 的同步逻辑直接执行校验存储可以丢到一个宏任务里input.addEventListener(input, (e) { // 同步更新 UI立即反馈 updateUI(e.target.value); // 延迟到后续空闲时间处理避免卡顿 setTimeout(() { validateAndSave(e.target.value); }, 0); });如果校验存储逻辑太重还可以进一步用requestIdleCallback只在浏览器空闲时执行。这种思路本质上就是在利用事件循环的调度特性做性能优化。6.3 Web Worker单线程限制下的“逃生舱”虽然 JS 主线程必须单线程处理 DOM 相关事务但我们并不是完全没有多线程的手段。Web Worker 就是浏览器提供给开发者的“后台线程”可以在里面跑计算密集型的任务不占用主线程。Worker 和主线程之间通过postMessage通信消息的接收回调同样是事件驱动的异步任务。比如你要做大量数据的加密、图片处理、复杂数学运算都可以扔给 Workerconst worker new Worker(./worker.js); worker.onmessage (e) { console.log(计算结果, e.data); }; worker.postMessage({ type: compute, data: largeData });需要注意的是 Worker 里没有 DOM API不能直接操作页面但普通计算、fetch 请求都没问题。它不会改变事件循环的规则只是把一部分任务挪到了另一个线程让主线程能腾出更多时间去处理渲染和交互。6.4 理解事件循环之后的代码设计观当你真正理解了同步、异步、微任务、宏任务的关系后写代码的方式会发生一些变化。比如你不会再傻乎乎地在一个循环里await一串串行任务你会知道Promise.all可以并行执行多个互不依赖的异步操作你也能够理解为什么在某些框架里状态更新看起来是“异步”的——本质上很多框架是把多次状态修改批量放进微任务队列最后统一渲染。我还想多说一个真实踩坑经验之前我负责的某个基础库因为在一个高频触发的函数里创建了过多的微任务导致某些用户低端手机上页面明显掉帧。后来排查到是每次点击都触发了几十次 Promise 链每次 Promise 链又都在微任务里更新 DOM。最后把这些逻辑合并成一次“批量提交 微任务统一渲染”掉帧问题直接消失。说白了事件循环不仅是个面试题它是你日常代码质量的底层支撑。最后再说一点个人体会学事件循环最忌讳只背输出顺序。那些顺序本质上都是“调用栈 宏任务队列 微任务队列”的推演结果。我建议你每次遇到迷惑的异步代码都先把任务画成三列同步区、微任务堆、宏任务堆然后手动过一遍流程。画过五六道题之后再看到异步代码你会发现自己已经有了肌肉记忆不再需要死记硬背任何结论。这就是我钻研 JavaScript 这些年觉得最受益的方法希望你也能从我这些经验里少走点弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →