ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Promise执行机制深入解析:事件循环、微任务与宏任务的调度真相

Promise执行机制深入解析:事件循环、微任务与宏任务的调度真相 很多前端同学把 Promise 的状态机背得滚瓜烂熟——pending、fulfilled、rejected张口就来但一到实际问题就原形毕露setTimeout和.then()谁先执行链式then为什么有时输出顺序和直觉完全相反线上突然冒出来uncaught (in promise)报警却不知道 Promise 是在哪一步炸的。我最早也吃过这个亏。有次排查一个接口竞态问题数据一会儿对一会儿错最后发现根本不是后端返回慢而是我对 Promise 回调和宏任务、微任务的入场顺序判断错了。这篇文章不打算再抄一遍文档而是把我自己从背状态到推得出执行顺序的理解路径完整拆开结合真实报错场景把这套机制彻底讲透。适合刚学 Promise 的人建立正确模型也适合写了两三年 JavaScript 但偶尔还是被事件循环问住的人查漏补缺。1. 状态、回调和任务队列是三码事先认清这三点执行顺序才有解1.1 pending 是一切起点Promise 状态机的三条铁律Promise 的状态确实只有三个pending进行中、fulfilled已成功、rejected已失败。但如果你只记住这三个词等于什么都没记住。真正影响代码行为的是状态流转的三条铁律。第一状态只能从pending出发变成fulfilled或rejected而且这个变化是单向的、不可逆的。一旦从pending变成fulfilled你就再也没办法把它掰回pending也没办法改成rejected。反过来也一样。这个设计很像现实里的订单状态下单后可以是已支付或者已取消但你不可能让一个已支付的订单重新变成待支付。第二resolve()和reject()只是触发状态变化并不保证状态立即被消费。换句话说Promise 在改变状态的那一刻只是把挂在它身上的回调登记到微任务队列里回调真正的执行要等当前同步代码跑完。第三new Promise((resolve, reject) {})里的这个函数叫 executor它是同步执行的。这点非常容易踩坑。很多人以为 Promise 里的一切都是异步的实际上只有.then()、.catch()、.finally()里的回调才异步executor 里的代码和你写在外面的普通代码没有任何区别该同步立刻同步执行。new Promise((resolve) { console.log(executor 执行了); // 同步输出 resolve(成功); }); console.log(外层同步代码); // 接着输出 // 执行结果 // executor 执行了 // 外层同步代码这个顺序很多人第一次跑都会被吓到但理解了executor 同步、回调异步之后Promise 执行机制的地基就算打好了。1.2 回调注册与回调执行之间隔着一整个事件循环理解了状态机下一个必须想明白的问题就是注册回调和执行回调根本不是一回事。当你写下promise.then(callback)你做的只是把callback放进了一个待执行列表。这个列表在 Promise 的机制里就是微任务队列。只有等到当前正在执行的那段代码也就是主线程的当前宏任务全部跑完JavaScript 引擎才会回头来处理这个微任务队列。很多人理解偏差就出在这里以为.then()写在哪个位置回调就在哪个位置执行。实际上.then()的位置只决定了回调和哪个状态绑定以及它进入微任务队列的先后顺序真正执行要看事件循环的节奏。有个生活化的比喻我经常用主线程执行同步代码就像你在餐厅按菜单顺序上菜每道菜是一个同步操作。.then()注册回调不是立刻加菜而是服务员在你的订单上记了一笔等这桌吃完再上甜点。甜点什么时候上肯定要等当前这轮服务结束而且还要排在同样等待的外带订单前面。这个外带订单就是宏任务。一旦把状态变化和回调执行拆成两个独立事件很多复杂度就迎刃而解状态是原因回调执行是结果中间通过任务队列建立了时序关系。2. 宏任务是主线剧情微任务是每集结束后的彩蛋事件循环调度全拆解2.1 一个完整 tick宏任务执行、微任务清空、渲染让位标题里说宏任务是主线、微任务是穿插这个直觉是对的但具体机制得再拧紧一圈。JavaScript 是单线程语言所有代码都得排队在主线程上跑。你可以把整段程序想象成一部连续剧宏任务就是正式播出的正片微任务是每集正片结束后的彩蛋或预告片。但这里有一个关键规则正片可以有无数个但每集正片结束、下一集正片开始之前所有彩蛋必须全部放完哪怕这期间又冒出来新的彩蛋也得在这一轮全部放完。对应到事件循环一个完整的 tick 是这样走的从宏任务队列取出一个宏任务执行比如整段script代码、一个setTimeout回调、一次事件处理函数。这个宏任务执行过程中凡是.then()、queueMicrotask()等产生的微任务全部进入微任务队列。当前宏任务执行完毕开始清空微任务队列。注意是清空不是执行一个。如果微任务里又注册了新的微任务新微任务也会在这一轮被继续执行直到微任务队列彻底为空。浏览器视情况决定是否执行一次渲染render。回到第 1 步取下一个宏任务。截图看代码会更直观setTimeout(() { console.log(宏任务 A); Promise.resolve().then(() { console.log(A 产生的微任务); }); }, 0); setTimeout(() { console.log(宏任务 B); }, 0); Promise.resolve().then(() { console.log(脚本同步段产生的微任务); });推演一下完整输出第一个宏任务是整段script。它注册了两个setTimeoutA 和 B还注册了一个微任务。script执行完清空微任务队列输出脚本同步段产生的微任务。取宏任务 A输出宏任务 A又注册了一个微任务。A 执行完清空微任务队列输出A 产生的微任务。取宏任务 B输出宏任务 B。所以最终顺序是脚本同步段产生的微任务 → 宏任务 A → A 产生的微任务 → 宏任务 B。这个顺序就是事件循环的真实呼吸节奏。2.2 产生宏任务和微任务的 API 清单与优先级错觉做执行顺序题的时候先判断一个操作属于宏任务还是微任务题目就做对了一半。浏览器环境里常见的宏任务来源script整体代码setTimeout/setIntervalI/O 事件、UI 交互事件MessageChannelpostMessage了解即可常见的微任务来源Promise.then/catch/finallyqueueMicrotask()MutationObserverNode.js 环境里还要额外注意process.nextTick它的优先级比 Promise 微任务更高会在当前阶段结束后立刻执行。这就经常造成一种明明nextTick写在后面却先执行的现象。我见过不少人把Promise.resolve().then()当成异步执行把setTimeout(fn, 0)也当成异步执行然后以为两者差不多。实际上它们根本不在一个优先级每轮宏任务结束后必然先清空微任务因此微任务永远抢在下一个宏任务前面。就算setTimeout(fn, 0)的延时是 0也要等当前宏任务执行完、清空微任务队列然后才能轮到它。3. 七道题把 Promise 输出顺序练成肌肉记忆完整的逐步推演这块是我最想写的部分。做 Promise 执行顺序题光看答案没用得练出看到代码脑子里能跑起事件循环的能力。我挑了七道由浅入深的题每道都给出完整的推演过程。3.1 第一组setTimeout 与 Promise.then 的起跑线入门题但能筛掉一批基础不牢的人console.log(1); setTimeout(() { console.log(2); }, 0); new Promise((resolve) { console.log(3); resolve(4); }).then((value) { console.log(value); }); console.log(5);逐步推演同步代码开始执行console.log(1)输出1。遇到setTimeout把它丢进宏任务队列继续往下走。进入new Promiseexecutor 同步执行输出3resolve(4)把 promise 变成fulfilled于是.then回调进入微任务队列。console.log(5)输出5。当前宏任务script结束清空微任务队列输出4。宏任务队列里的setTimeout执行输出2。最终输出1 3 5 4 2。这道题的价值在于console.log(3)和resolve(4)发生在同步阶段但.then回调要等同步代码全部结束后才能执行。很多人以为 Promise 内部全是异步这是第一个要纠正的错觉。3.2 第二组链式 then、catch 的状态传递看链式then的时候脑子里要始终记得一点每次.then()或.catch()都会返回一个全新的 Promise。新 Promise 的状态取决于回调的返回值或是否抛错。Promise.resolve(成功) .then((res) { console.log(then1:, res); throw new Error(出错了); }) .catch((err) { console.log(catch1:, err.message); return 恢复; }) .then((res) { console.log(then2:, res); });推演Promise.resolve(成功)直接得到一个fulfilled的 promise。then1回调进入微任务队列。执行时输出then1: 成功然后throw new Error。这个 throw 让then1返回的 Promise 变成rejected。.catch捕获了上一个 Promise 的rejected进入微任务队列执行输出catch1: 出错了并return 恢复。返回普通值让catch返回的 Promise 变成fulfilled。最后的.then收到这个fulfilled输出then2: 恢复。最终输出then1: 成功→catch1: 出错了→then2: 恢复。这个例子说明catch不一定让整条链结束它只是把状态从rejected又掰回了fulfilled。所以catch 之后还能不能 then这个问题答案是能而且很常用。3.3 第三组微任务排队时新产生的任务怎么处理这组题是真正的分水岭。很多人知道要清空微任务队列但不知道清空意味着执行过程中新加入的微任务也会被一起执行完。setTimeout(() { console.log(timeout1); Promise.resolve().then(() { console.log(micro1); }); }, 0); setTimeout(() { console.log(timeout2); }, 0); Promise.resolve().then(() { console.log(micro2); });推演script宏任务执行注册两个setTimeout注册一个微任务micro2。script结束清空微任务队列输出micro2。取宏任务timeout1输出timeout1执行中又注册了微任务micro1。timeout1执行完清空微任务队列输出micro1。注意这轮清空必须等micro1执行完才算完而不是清一个就跑去取下一个宏任务。取宏任务timeout2输出timeout2。最终输出micro2→timeout1→micro1→timeout2。很多人会写成micro2→timeout1→timeout2→micro1就是没理解清空是彻底清空。微任务队列只要还有任务下一个宏任务就没有机会上场。再看一道更考验队列感的题Promise.resolve() .then(() { console.log(a); setTimeout(() { console.log(b); }, 0); }) .then(() { console.log(c); }); setTimeout(() { console.log(d); }, 0);推演同步阶段注册一个setTimeout(d).then链上的第一个回调进入微任务队列。清空微任务队列第一个.then执行输出a同时注册了setTimeout(b)。这个回调执行完它返回的 Promise 变成fulfilled于是第二个.then回调在当前这轮清空中继续被取出执行输出c。微任务清空进入宏任务阶段。宏任务队列里有两个任务d是先注册的b是后注册的所以先输出d再输出b。最终输出a→c→d→b。这道题的关键是理解链式 then 的第二个回调也是微任务而且有可能和第一个回调在同一轮清空中执行。因为第一个.then执行完立刻让新 Promise 变为fulfilled第二个.then就顺势被推入当前微任务队列尾部然后被这轮清空顺带处理掉。再加一道带async/await的综合题考察 await 的语义async function foo() { console.log(a); await bar(); console.log(b); } async function bar() { console.log(c); return d; } console.log(e); foo(); console.log(f);推演同步代码输出e。调用foo()foo内部同步执行到await bar()之前输出a。执行bar()bar内部同步输出c返回一个已fulfilled(值为 d) 的 Promise。await会把foo中后续代码console.log(b)包装成微任务然后让出主线程。回到全局同步代码输出f。微任务阶段执行console.log(b)输出b。最终输出e→a→c→f→b。这道题如果对async/await不熟极容易写成e a c b f。记住一条await的右边会先同步执行完await的左边后续代码会在微任务里恢复执行。4. 热搜报错复盘unhandled rejection 和 uncaught in promise 的真实现场掌握了执行机制之后再回头看那些高频报错会清楚很多。很多报错不是语法问题而是异步时机和状态管理的问题。4.1 listener indicated an asynchronous response异步监听器的响应承诺超时这个报错在浏览器扩展开发里特别常见。完整的报错一般是类似Uncaught (in promise) Error: A listener indicated an asynchronous response by returning a Promise, but the message channel closed before a response was received.拆开看就清晰了。浏览器扩展的chrome.runtime.onMessage.addListener支持回调函数返回 Promise 来进行异步响应浏览器会等这个 Promise 落定后再把响应回传。但如果监听器返回了一个 Promise而消息通道在 Promise 落定之前就关闭了比如页面跳转、扩展重新加载、没有调用sendResponse也没返回 Promise就会出现这个错误。这本质上就是Promise 状态还没到终点外部上下文已经没了。我在处理这类问题时的经验是如果监听器不需要异步响应就明确返回undefined不要随手返回 Promise。如果必须异步响应确保在 Promise.then()里调用sendResponse并且函数末尾return true保持通道开启。监听器内部所有异步分支都要有catch否则某个分支抛出 rejected Promise就会变成unhandled rejection。这个报错的排查思路放到普通前端项目里同样适用只要你在生命周期可能提前结束的地方比如页面卸载、组件销毁使用了 Promise就要考虑通道关闭与 Promise 落定之间的竞态。4.2 insertBefore 失败和 cannot read properties of undefined异步回调里的失效引用热搜词里还有一条很典型的 DOM 报错Uncaught (in promise) NotFoundError: Failed to execute insertBefore on Node: The node before which the new node is to be inserted is not a child of this node.这个报错出现在 Promise 回调里往往不是因为insertBefore本身写错了而是异步回调执行时DOM 已经被更新过了。举个我实际遇到过的场景列表加载完成之后往某个容器里插入新节点。代码大概是这样的fetchData().then((data) { const placeholder document.getElementById(placeholder); container.insertBefore(newNode, placeholder); });如果在这段代码执行之前有其他逻辑因为状态变化重新渲染了整个列表旧的placeholder节点已经被移动或删除那么insertBefore就会报参考节点不是此节点的子节点。这类问题的根治思路是不要在异步回调里持有历史 DOM 引用重新查询节点或者保证渲染逻辑和异步插入逻辑走同一个数据流。把 DOM 操作收敛到渲染函数里用状态驱动而不是在 Promise 回调里直接操作某个可能已过期的节点。类似的还有cannot read properties of undefined (reading ...)这个更常见接口返回的数据结构和预期不一致或者响应还没回来就试图读数据。比如fetchData().then((res) { console.log(res.data.list[0].name); // res.data 或 list 可能是 undefined });这个报错反复出现在热搜里说明它不是冷门问题。它的本质是Promise 成功不代表数据结构符合预期。网络请求能返回 200不代表返回体里的字段都齐全。所以判断一个接口返回正确性的标准不能只看 Promise 变成fulfilled还要做结构校验。4.3 三查法状态、队列、业界的实战排障套路把上面这些报错归纳一下我总结了一个三查法遇到 Promise 相关的诡异报错按顺序排查基本能覆盖大部分场景。第一查状态找出报错的 Promise 是在哪个环节变rejected的。可以用catch临时打日志或者通过unhandledrejection事件拿到详细的 rejection 对象打印它的 stack。看到 stack 的瞬间80% 的问题都能定位到具体代码行。第二查队列确认回调执行的时机是否被事件循环延后。如果报错涉及 DOM 过期、全局变量被改写想想 Promise 回调执行之前是不是有其他异步任务已经改变了现场。这也是时序 bug的高发区域。第三查生命周期排查 Promise 的调用链是不是跨了组件、页面、甚至扩展消息通道等有明确生命周期的上下文。一旦上下文关闭Promise 回调再努力也没用。说到unhandled rejection我还想多说一句。它和uncaught exception不一样它发生在 Promise 的状态变成rejected之后却没有任何catch或then的 rejected 分支来处理这个状态。浏览器等了一段时间发现这个 Promise 的 rejected 状态没人认领就在全局抛出一个unhandledrejection事件。Node.js 环境下就是unhandledRejection进程事件。实际项目里最容易产生这个问题的场景是忘记 return Promise。比如function doSomething() { fetchData().then((res) { // 处理数据 }); // 没有 return } doSomething(); // 如果 doSomething 内部没人 catchrejected 就会变成 unhandled rejection这里的根子是fetchData()产生的 Promise 在doSomething里被丢弃了外层无法感知它的失败。所以我在团队里会强制要求函数内部创建 Promise要么 return 出去要么在内部终结。5. 从执行机制反推编码规范让 Promise 代码更稳的几条个人实操建议理解了执行机制不转化成编码习惯有点亏。下面这几条都是我在项目里踩过坑之后逐步沉淀下来的实操经验。5.1 禁止裸奔 Promise链尾兜底 全局监听我见过很多事故根源就是某个 Promise 没有兜底catch失败后静默吞掉或者等到用户报障才发现。我的习惯是两条腿走路。第一业务代码里凡是新建 Promise 链必须在链尾留一个catch。哪怕只是打日志也要留。不能因为这个接口不太会失败就不写线上什么事情都可能发生。第二在应用入口加全局兜底监听。浏览器环境window.addEventListener(unhandledrejection, (event) { console.warn(未处理的 Promise 拒绝:, event.reason); event.preventDefault(); // 阻止默认的错误输出视团队规范决定用不用 });Node.js 环境process.on(unhandledRejection, (reason, promise) { console.error(Unhandled Rejection at:, promise, reason:, reason); });这样即使有遗漏也能第一时间在日志系统里看到而不是等用户来投诉。5.2 await 不是同步竞态与悬挂请求的处理用async/await写代码会让人产生一种这就是同步代码的错觉但执行机制里它仍然是微任务调度。最常见的两个坑第一个是竞态。多个异步请求并发时最后返回的不一定是最先发起的如果直接用返回值覆盖状态就会出现旧响应覆盖新响应。处理方案有很多我推荐在业务层维护一个递增的请求序号响应回来时比对序号或者直接用AbortController取消过期请求。const controller new AbortController(); fetchData({ signal: controller.signal }); // 切换参数或组件卸载时 controller.abort();用AbortController还有一个额外好处请求被取消后fetch 返回的 Promise 会变成rejected不会悬挂在队列里浪费资源。悬挂请求是最隐蔽的资源泄漏方式之一你以为没影响其实 Promises 队列里堆了一堆永不落定的任务。第二个是await让出了执行权。在await之后的代码本质是微任务回调。如果在await之后依赖某些外部变量而这些变量可能在微任务执行前被其他代码修改就会产生奇怪的结果。这种问题很难靠调试看出来最好就是减少异步边界上的共享可变状态把状态变化收敛到单一数据流里。5.3 复杂异步流的翻译套路链式转 async/await 时的注意点最后分享一个我重构代码时常用的翻译套路。把链式then改成async/await不是单纯的语法替换而是要做三件事第一把原来的.then((value) {})改成let value await promise这一步等价。 第二把.catch((err) {})改成try { ... } catch (err) { ... }。但要注意try/catch的捕获范围和.catch不一样。.catch只捕获它之前那条链上的 rejectedtry/catch会捕获整个try块里所有同步和异步的错误。转换时要确认错误边界没有变化。 第三把中间产生的新 Promise 单独拆出来。比如// 转换前 fetchData() .then((res) process(res)) .then((result) save(result)) .catch((err) log(err)); // 转换后 let res; let result; try { res await fetchData(); result await process(res); await save(result); } catch (err) { log(err); }这种写法可读性更好但执行机制本身没有变化await之间依然是微任务在调度。千万不要以为await就是把异步变成了同步它只是让代码长得像同步而已。理清 Promise 的执行机制本质上就是弄清楚三件事状态什么时候变、回调什么时候入队、队列什么时候被清空。这三件事搞明白了绝大多数 Promise 相关的执行顺序题和线上诡异报错都能顺着事件循环推出来。我自己在项目里维持的习惯是每写一个 Promise 链都会在脑子里跑一遍它的执行时间线——如果某项操作的时序依赖隐式前提就顺手加注释或者直接重构别让后来的人靠猜。
返回列表