尧图精选

Promise执行机制全面解析:状态、微任务与并发控制

🕒 发布时间:2026/10/2 11:20:58 📁 来源:尧图网络
关于Promise的执行机制面试考得最多但实际开发里真正弄明白的人并不多。很多前端拿得出手“三种状态”“宏任务微任务”这些词真到了排查问题时却连uncaught (in promise)的报错从哪冒出来的都说不清楚。这篇文章我准备把Promise的执行机制完整过一遍重点放在两件事上一是三条状态的流转逻辑二是宏任务和微任务穿插执行时事件循环到底在跑什么。过程中我会穿插一些真实项目里高频踩坑的报错比如unhandled promise rejection、too many pending requests、cannot read properties of undefined这种看看它们究竟是怎么被Promise机制“放出来”的。先把结论放这Promise做的事情并不复杂它就是一道“排队状态登记”的机制复杂的是它和事件循环、微任务队列缠在一起后产生的时序问题。你真正理解了这套时序再去排任何异步问题思路都会顺很多。1. Promise 的三态一个不可逆的状态机1.1 pending 是一切异步动作的起点Promise刚被创建出来时状态一定是pending。这个状态的中文翻译是“进行中”但它英文原意其实更准一点——事情还没定论、正在等结果。const p new Promise((resolve, reject) { // 这里开始执行一些异步操作 }); console.log(p); // Promise { pending }你可能会说这种一眼就能看出来的东西还需要讲吗需要的。因为pending状态在实际开发中最容易被误解尤其是当它和“请求发出去了”混在一起的时候。很多人以为Promise进入pending就代表请求已经发出去了、网络数据正在回来的路上。实际上Promise本身只是一个壳子它的状态跟你的异步任务是不是真的“在进行”没有必然关系。你在executor里写了个setTimeout如果这个定时器被设置成了10分钟那Promise也会老老实实pending10分钟。又比如你在executor里什么都没干只调用了resolve()Promise会立刻变成fulfilled根本不会经过什么“等待”过程。这也是为什么有些竞态条件特别难查——不是Promise的问题是你把“pending”这个状态当成了异步任务还在执行的标志但Promise压根不管你的任务在干嘛。它只关心一件事executor里的逻辑最终有没有调用resolve或reject。所以看待Promise的正确姿势是它是一个“结果登记簿”不是“任务执行器”。任务在哪儿跑、跑得怎么样了Promise都不关心它只负责在那两种结果之间选一个登记下来。1.2 fulfilled 和 rejected结局只会有一种fulfilled和rejected是Promise唯二的终态。一旦从pending变成了这两个状态之一状态就不可能再变回去也不能再变成另一个终态。const p new Promise((resolve, reject) { resolve(success); // 第一次决议 reject(fail); // 这行无效 resolve(again); // 这行也无效 }); p.then(value console.log(value)); // success这段代码可能很多人见过但没认真想过它的底层原因。Promise的状态设计是单向且不可逆的底层实现里类似于一个settled的布尔开关一旦置为true后面的resolve和reject调用全部跳过。这个设计带来的直接好处是确定性。在回调地狱的年代一个异步操作可能被回调触发两次、三次你根本不知道哪次是最后的结果。而Promise通过状态不可逆保证了“一次决议永不变更”。这在实际业务里有个很好的使用场景重复提交问题。你把接口请求封装成Promise后就算用户在UI层面防重复点击做漏了后端返回的结果也只会有一次生效。后续再调用resolve状态机上就被“无视”了。1.3 为什么状态必须不可逆很多人没有去深究过这个“不可逆”到底解决了什么问题。我换个说法你就明白了——状态不可逆意味着所有订阅这个状态变化的.then回调拿到的结果必然是同一个值而且必然只执行一次。如果状态可以来回切换那你写的代码就变成了这样// 假设状态可以逆转 promise.then(value { // 你不知道这个回调会被触发几次也不知道拿到的value会不会变 });还没有一个可靠的状态来区分“已经结束”和“还在变化”。所有依赖这个Promise的后续逻辑都会变得不可预测比如Promise.all根本不知道该等哪个结果错误处理也不知道该在什么时机切入。状态不可逆看着是个小细节实际上是Promise整个可靠性的地基。理解了这一点再看pending到fulfilled、pending到rejected的两条转移路径整个Promise机制的主框架就算拿下来了。但真正的难点不在状态本身而在于状态改变之后那串.then回调到底是什么时候执行的、怎么排进任务队列的——这就轮到宏任务和微任务登场了。2. 宏任务与微任务看清事件循环的排队逻辑2.1 宏任务是主线微任务是穿插标题里说的“宏任务主线、微任务穿插”其实就是理解事件循环最形象的比喻。JavaScript是单线程语言同一时刻只能干一件事。但浏览器/Node环境里很多任务并不会当场执行完比如定时器、网络请求、用户事件回调它们会被放进一个待办队列等主线空下来再处理。这些进队列的任务就是宏任务包括setTimeout、setInterval、I/O操作、UI交互事件等。而微任务是一个优先级更高的队列专门给Promise.then/catch/finally、queueMicrotask、MutationObserver这些回调用的。微任务最大的特点是它会在当前宏任务结束之后、下一个宏任务开始之前把队列里积攒的所有微任务一次性清空。执行顺序的完整流程是执行当前宏任务包括所有同步代码检查微任务队列把所有微任务挨个执行完执行过程中新产生的微任务也会在本轮清空浏览器如果需要渲染页面就在这个间隙执行渲染从宏任务队列取出下一个宏任务回到第1步可以理解为宏任务就像排队办事的客户微任务则像是客户办完事后立刻插进来的加急件而且加急件内部还能再产生加急件一直处理到没有加急件为止。这个过程周而复始就构成了事件循环。2.2 微任务队列在宏任务之后被完整清空这个“完整清空”很重要但很多人的理解是错的。我见过不少人以为微任务每次事件循环只执行一个实际上只要微任务队列里有东西事件循环会一直执行到队列空为止。看这段代码就明白了Promise.resolve() .then(() { console.log(micro 1); Promise.resolve().then(() console.log(micro 2)); }); setTimeout(() console.log(macro 1), 0);输出顺序是micro 1 micro 2 macro 1注意micro 2是在micro 1里新创建的微任务但它并没有被排到下一个宏任务之后执行而是在当前宏任务结束前就被清空了。所以在事件循环的视角里微任务是一个“必须清到空为止”的循环而不是一轮只处理一个。这也带来一个容易被忽略的坑微任务里如果不断产生新的微任务就会导致宏任务永远得不到执行页面看起来就像卡死了一样。比如一段递归的Promise.resolve().then(...)如果递归条件判断有误它会阻塞整个事件循环后面的setTimeout一个都不会触发。这种问题在本地测试时特别容易忽略因为逻辑不复杂、循环次数少。一旦数据量上来页面突然毫无响应你直接怀疑代码逻辑却想不到是微任务队列把宏任务饿死了。2.3 then 与 await 的时序差异在函数内部使用await时时序处理和.then有细微区别这个点必须单独拿出来讲。await本质上是在编译阶段把后面的代码改造成类似.then的微任务形式但它和直接写.then不完全等价。最典型的差异看这个例子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(); Promise.resolve().then(() console.log(promise1)); console.log(script end);很多人的第一反应是async1 end在promise1之前因为async1整体看起来是先声明的。但主流JavaScript引擎的实际输出是script start async1 start async2 script end promise1 async1 end setTimeout这里的关键在于await async2()时async2()同步执行完并返回了一个已决议的Promise而await后面要恢复执行的代码需要经过一个额外的Promise解析流程才能继续。相比之下Promise.resolve().then(...)的回调注册得更直接、更靠前所以promise1先被执行了。很多人第一次遇到这个输出时非常不解但它不是偶然现象而是引擎内部的Promise解析步骤PromiseResolveThenableJob导致的。这也从侧面说明了如果你在项目里发现一段看起来顺序不对的异步代码不要先怀疑“是Promise机制有bug”而要先怀疑自己是否准确理解了微任务的入队顺序。3. Promise 异常的通病与排查实录3.1 抛在 Promise 里的错为什么经常被“吞掉”Promise最让人头大的问题不是状态流转而是异常。很多新手会在Promise里写这样的代码new Promise(() { setTimeout(() { throw new Error(boom); }, 100); }).catch(() console.log(捕获到错误));你以为.catch一定能接住这个错误但实际控制台直接报Uncaught Error: boom.catch完全没生效。原因在于throw发生在setTimeout的回调里这个回调本身不在Promise的executor执行栈中Promise管不到它。Promise能捕获的异常范围只有两类executor函数体里的同步代码.then/.catch/.finally回调本身执行时的同步代码所以new Promise里的异步嵌套回调比如setTimeout、事件监听器内的异常它是接不住的。这就导致了一种很尴尬的情况业务代码里有人在Promise的executor里发请求又在then回调里抛了个异常你看到控制台一片红但.catch明明写了为什么没拦住你仔细排查之后才发现异常根本不是Promise链上抛的而是某个事件回调里抛的。排查思路其实很简单看报错栈里那行代码到底是在Promise链上执行的还是在某个监听器/定时器回调里执行的。后者只能靠外层try/catch或全局错误捕获去兜底。3.2 热榜上那些 uncaught (in promise) 报错拆解网上搜索Promise相关问题时出现频率最高的关键词就是uncaught (in promise)。这个报错的完整含义是某个Promise被rejected了但是这条Promise链上没有任何.catch或await来处理它。我把几个搜索热度很高的具体报错做了整理它们在真实项目里都很典型。报错文本常见场景排查方向uncaught (in promise) error: a listener indicated an asynchronous response b浏览器扩展消息监听中回调返回了true表示异步响应但从未调用sendResponse检查扩展消息监听器是否漏调了sendResponseuncaught (in promise) notfounderror: failed to execute insertbefore on nodeDOM节点已经被移除或位置非法Promise回调中仍在做insertBefore在操作DOM前校验节点是否仍存在于文档中uncaught (in promise) typeerror: cannot read properties of undefined请求返回后数据结构不符合预期直接读取了undefined的属性对接口返回数据做防御式校验别直接链式取值uncaught (in promise) error: could not establish connection. receiving end does not exist消息通道的另一端扩展background/iframe已经被销毁发送消息前确认接收端仍然存活或捕获错误后做降级处理unhandled promise rejection typeerror: webassembly.instantiate()WebAssembly编译/实例化失败Promise被rejected但没被处理给WebAssembly.instantiate加上catch打印具体错误码拿第一个扩展报错来说它的根因就特别典型。浏览器扩展监听消息时为了支持异步响应需要让回调函数返回true同时后面手动调用sendResponse。但很多人写完异步逻辑后忘了最后那一下sendResponseChrome等到超时就会报这个a listener indicated an asynchronous response。整个过程涉及的东西全是Promise式的异步流程一旦漏掉结尾就会出现这类“听着很抽象、实际上特别具体”的报错。3.3 全局兜底与超时控制虽然规范的姿势是每条Promise链都接上.catch但实际项目里总会有漏网之鱼。为了不让漏掉的rejected Promise变成无头悬案浏览器和Node都提供了全局监听。浏览器环境window.addEventListener(unhandledrejection, (event) { console.error(未处理的Promise错误, event.reason); event.preventDefault(); // 可选阻止默认的报错日志 });Node环境process.on(unhandledRejection, (reason, promise) { console.error(Unhandled Rejection at:, promise, reason:, reason); });加了全局兜底之后至少不会让异常静默消失。但要注意全局兜底不等于不用写catch。它只能帮你发现错误、做上报并不能帮你从错误的逻辑中恢复现场。除了异常兜底Promise还有一个能力值得专门聊一下——超时控制。Promise自身没有任何“超时”概念pending状态可以永远持续下去。如果某个请求因为网络问题一直没有返回也没有触发reject那你的Promise就会一直挂着。业界最通用的做法是用Promise.race配合setTimeout实现超时function withTimeout(promise, ms, message 请求超时) { let timer; const timeoutPromise new Promise((_, reject) { timer setTimeout(() reject(new Error(message)), ms); }); return Promise.race([promise, timeoutPromise]).finally(() { clearTimeout(timer); }); }Promise.race的语义是“谁先决议就采用谁的结果”。如果请求先返回就正常继续同时清理超时定时器如果超时先触发就整体走到rejected配合调用方的catch做错误提示。这是避免“永远pending”的唯一通用手段。4. 并发场景下的 pending 与流量控制4.1 Promise.all 与 too many pending requests再来看一个搜索热词stream disconnected before completion: too many pending requests, please retry。这个报错在数据采集、爬虫、批量导出的场景里特别常见。它的本质是你一次性用Promise.all发起了大量请求比如几百个甚至上千个并发请求同时打向后端后端的连接池被塞满或者某个中间件限制了单个客户端的最大待处理请求数导致多余的请求直接被断开。下面这段代码就是典型的高危写法const urls getHugeUrlList(); // 比如500个接口地址 const results await Promise.all( urls.map(url fetch(url).then(res res.json())) );逻辑上完全没问题但实际跑起来就是会报too many pending requests。原因不是Promise机制有问题而是并发数没有控制。控制并发的思路有几层。第一层是业务层限制比如把500个请求切分成每20个一批串行去跑第二层是依赖现成的并发控制库比如p-limit第三层是自己实现一个简单的并发池很多场景下这个更轻量。4.2 给 Promise 加一个并发上限我自己的项目里经常用这样一个简易并发池逻辑清晰不需要引额外依赖async function mapLimit(tasks, limit, handler) { const results []; const executing []; for (const task of tasks) { const p Promise.resolve().then(() handler(task)); results.push(p); if (tasks.length limit) { const guard p.then(() { executing.splice(executing.indexOf(guard), 1); }); executing.push(guard); if (executing.length limit) { await Promise.race(executing); } } } return Promise.all(results); }用法很简单const results await mapLimit(urls, 10, async (url) { const res await fetch(url); return res.json(); });这个实现的核心在于Promise.race(executing)这句。当正在跑的Promise数量达到上限后它会在“第一个跑完”的时候继续循环而不是傻等所有Promise结束。这样既能保证并发数始终不超过limit又不会让总耗时长到不可接受。实际用下来我把请求并发从几百降到10到20之间too many pending requests基本就消失了而且整体耗时的增加完全可以接受。在IO密集场景下20个并发和500个并发的总耗时差异可能只有一两秒但对服务端的压力是完全不同量级的。4.3 不要把 pending 当作永久等待超时设计建议顺着刚才的超时控制继续往后说并发场景里还有一个特别容易被忽略的问题如果你的并发池里某一个请求永远处于pending即使你限制了并发数池子也可能被卡住。比如mapLimit里一个请求发出去了后端一直没有响应也没有断开连接。这个Promise就一直pending而Promise.race(executing)在等待的又恰好是它整个池子就会一直停在这个位置后续所有任务全部排队等待。解决办法就是在每个请求上直接套超时控制保证任何一个request最多只占用固定时间const fetchWithTimeout (url) withTimeout(fetch(url), 10000, ${url} 请求超时); const results await mapLimit(urls, 10, async (url) { const res await fetchWithTimeout(url); return res.json(); });这里有个设计经验超时时间宁可短一点也不要太长。我见过不少团队把超时设成60秒以为这样稳妥结果一个上游接口卡顿直接拖垮整条调用链路。超时不是一个精确的错误判断它只是一个兜底保护一般设置成你业务能容忍的最长等待即可比如10秒或15秒完全够用。另外一个建议是给超时错误打上标记。直接reject(new Error(请求超时))在日志里很难检索。可以给Error加一个属性比如error.name TimeoutError或者在reject前做统一包装这样后面的错误上报、告警、分组都会省事得多。4.4 从“pending editor decision”聊到状态展示最后聊一个有趣的现象。热搜词里有个pending editor decision它不是前端术语是学术论文投稿系统里的状态——意思是论文正在等待编辑决定。但这个名字恰好也是“pending”这个词最精髓的体现一个任务正在等待外部结果外部不响应它就永远挂着。很多前端团队在自己的业务系统里也会遇到类似场景。比如后台管理页面里有一个“数据同步中”“审核中”的状态它就是业务层面的pending状态。只要后端某个关键节点一直不返回前端页面上这个状态就会一直转圈。对这个问题的处理方式和Promise超时控制的思路是一样的任何业务里面的“等待”状态都必须设置一个合理的超时切断点。要么前端轮询接口发现超过阈值直接提示超时要么后端定时任务把超时任务置为失败。哪一种方案都可以但绝不能什么都不做让用户在一个没有尽头的过程面前干等。我在实际项目里见过一个典型案例一个导出报表的功能后端生成报表需要几分钟前端发起请求后就一直轮询。结果有一次后端队列卡死任务既没成功也没失败前端就永远卡在“导出中”的状态用户刷新页面也看不见任何进度。后来我们加了一道超时机制超过5分钟直接视为失败用户体验提升非常明显。所以说pending是Promise天然存在的状态但业务上我们不能默认pending会自己结束。做超时、做中断、做状态过期识别这些才是把Promise机制真正用好的关键。我个人在项目里习惯把Promise的执行机制当成一套“排队协议”来看状态机负责记录结果微任务队列负责确定结果发出的时机宏任务则负责管理更大的时间节奏。搞清了这三者的分工再复杂的异步代码拆开也能看得明明白白。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →