关于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 | 检查扩展消息监听器是否漏调了sendResponse |
uncaught (in promise) notfounderror: failed to execute 'insertbefore' on 'node' | DOM节点已经被移除或位置非法,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。但很多人写完异步逻辑后忘了最后那一下sendResponse,Chrome等到超时,就会报这个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的执行机制当成一套“排队协议”来看:状态机负责记录结果,微任务队列负责确定结果发出的时机,宏任务则负责管理更大的时间节奏。搞清了这三者的分工,再复杂的异步代码,拆开也能看得明明白白。