我们团队几个月前被一个报错折磨了一晚上:小程序里请求一个接口,控制台扔出一行“Uncaught (in promise) MiniProgramError {"errmsg":"request:fail net::ERR_CERT_AUTHORITY_INVALID"}”。代码反反复复查了三遍,finally 也写了,catch 也加了,结果这个错误还是时不时冒出来。后来才发现,不是某个 try/catch 没写全,而是整条 Promise 链的执行流程从一开始就没理顺:谁先谁后、哪个 Promise 的状态在什么时机被消费、又在哪里被“吞掉”了,这些问题一旦不清楚,异步代码就是一团乱麻。
这篇文章就围绕 Promise 执行流程这一个核心展开。我会从最基础的状态机开始,一直讲到 then 链如何排队、async/await 的底层行为,再带你把几个经典“Uncaught (in promise)”现场过一遍。适合刚接触 Promise、被异步流程搞懵的新人,也适合想系统理清微任务执行顺序、顺手排查线上问题的前端同学。看完之后,你至少能回答这三个问题:Promise 的执行顺序由什么决定?为什么 Promise 里的错误总是“偷偷溜走”?遇到 uncaught (in promise) 该怎么快速定位?
1. 一次真实的 Promise 报错现场:从“uncaught (in promise)”说起
很多人第一次意识到 Promise 执行流程很重要,不是因为读到了规范文档,而是因为控制台跳出红字。我再分享一个更常见的场景:浏览器里想自动播放视频,写了 video.play(),然后控制台直接报:
code复制Uncaught (in promise) NotAllowedError: play() failed because the user didn't interact with the document first.
同样的代码,在开发环境手动点一次页面再播放没问题,但在某些自动播放逻辑里就会这样。为什么?因为 play() 是异步的,它返回一个 Promise。浏览器禁止没有用户手势的自动播放,所以内部会调用 reject(),把状态变成 rejected。此时,如果你没有给这个 Promise 挂接任何一个 .catch(),错误就会变成未处理的 rejection。控制台看到的那一排字,不是函数调用本身的问题,而是这条 Promise 链的末端没有消费者去接住失败状态。
再比如 Chrome 扩展程序里常用的 chrome.runtime.sendMessage()。如果你发送消息时接收端不存在,比如扩展的背景页被卸载了,或者 content script 没注入成功,调用方拿到的 Promise 也会被 reject,报错就是:
code复制Uncaught (in promise) Error: Could not establish connection. Receiving end does not exist.
这个案例特别典型:sendMessage 底层已经帮你封装成了 Promise,但你很容易“以为它只是同步发个消息”。其实它内部一旦发现接收端异常,会立刻把 Promise 置为 rejected。没有 catch,就变成 uncaught。
所以,理解“Promise 执行流程”不是停留在背概念层面,而是每一次异步调用都需要在脑子里画出一条路径:Promise 创建时执行了什么?回调什么时候进入微任务队列?rejected 之后谁会处理?没人处理的时候会发生什么?接下来我从状态机和微任务两块展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆解 Promise 执行流程:状态机、回调入队与微任务队列
Promise 的本质是一个带状态管理的容器。它把异步结果包装进一个对象里,然后通过 then、catch、finally 这几种方式订阅结果。执行流程可以简化成一句话:构造函数执行同步代码,resolve/reject 触发状态变化,then/catch 回调作为微任务等待执行。
2.1 三个状态和一个不可逆的迁移
Promise 的状态只有三种:
pending:等待中,既没有成功,也没有失败;fulfilled:已成功,称为 resolved;rejected:已失败。
其中最关键的一个特性是:状态只能从 pending 开始转变一次,而且一旦变为 fulfilled 或 rejected,就再也不能变回去了。我用一个生活类比:Promise 就像一把密码锁,等你输入了正确密码(resolve),锁就永久打开;输入错误密码(reject),锁就永久锁死,不可能再从成功切回失败,也不可能从失败切回成功。
代码上的体现是:
javascript复制const promise = new Promise((resolve, reject) => {
resolve(1);
reject(new Error('这个 reject 不会生效'));
resolve(2);
});
promise.then(
value => console.log('fulfilled:', value),
err => console.log('rejected:', err)
);
这段代码最终只会输出 fulfilled: 1。后面的 reject 和 resolve 都无效,因为第一次 resolve(1) 已经把状态变成 fulfilled。这就是不可逆迁移的意义:它可以保护回调只被调用一次,避免“成功又失败”这类竞态问题。
2.2 then、catch、finally 的回调入队逻辑
promise.then(onFulfilled, onRejected) 是注册回调的主要入口。很多人以为 .then() 是在 Promise 状态变化的那一刻立刻执行回调,其实不是。注册回调之后,回调会先进入一个队列,等当前脚本执行完毕,事件循环进入微任务处理阶段时才依次执行。
示例:
javascript复制console.log('A');
new Promise((resolve) => {
console.log('B');
resolve('C');
}).then(value => {
console.log(value);
});
console.log('D');
输出顺序是 A B D C,而不是 A B C D。原因在于:new Promise() 里的执行器(executor)是同步执行的,所以 B 立即打印;resolve('C') 只是把 Promise 置为 fulfilled,并把 then 中注册的回调放到微任务队列;但当前宏任务还没结束,得继续执行后面的 console.log('D'),等主线程空闲了再去处理微任务队列,最后才打印 C。
catch 本质上就是 then(undefined, onRejected) 的简写。finally 则不同:无论成功失败都会执行,但 finally 的回调不会接收结果,也不会改变 Promise 的 value 或 reason。执行流程上,finally 之后返回的新 Promise 仍然沿用之前的 fulfilled/rejected 状态,所以可以接着链式调用。
2.3 微任务队列为什么决定了执行顺序
JavaScript 的事件循环分成宏任务和微任务。常见的宏任务有 setTimeout、setInterval、I/O 回调,微任务则包括 Promise 的 then/catch/finally 回调、queueMicrotask、MutationObserver 回调等。
每一轮事件循环会先执行一个宏任务,然后清空当前微任务队列,再渲染或进入下一轮宏任务。这直接决定了 Promise 回调的执行时机:
javascript复制setTimeout(() => console.log('setTimeout'));
Promise.resolve()
.then(() => console.log('promise1'))
.then(() => console.log('promise2'));
console.log('sync');
这段代码的输出是 sync → promise1 → promise2 → setTimeout,而不是 setTimeout 挨着 sync 后面。Promise 回调是微任务,会在宏任务 setTimeout 之前被清空。无数面试题都在考这一点,但它不只是在考试里有用:真实业务里,如果你在 setTimeout 里调用了依赖微任务更新之后才有的数据,顺序错了就会拿到旧值。
另外,微任务队列是“清空”而不是“清一个”。一个微任务执行过程中如果又往队列里添加了新的微任务,那这些微任务会在本次“清空”阶段继续被取出来执行,直到队列为空。这就是为什么 Promise 层层嵌套时,看起来像是“一口气执行完”,中间不会插进其他宏任务。
3. 核心细节:resolve、reject 与 then 链的真实执行顺序
掌握了状态机和微任务队列,我们来看更具体的链式执行。then 链不是简单的“一个接着一个同步执行”,每一个 .then() 都会创建一个新的 Promise,并且把返回值和回调抛错都包装进新的 Promise 里。
3.1 代码示例:执行顺序推演
javascript复制console.log('script start');
const p = new Promise((resolve, reject) => {
console.log('executor start');
resolve(1);
console.log('executor end');
});
p.then(value => {
console.log('then1:', value);
return value + 1;
}).then(value => {
console.log('then2:', value);
throw new Error('boom');
}).catch(err => {
console.log('catch:', err.message);
});
console.log('script end');
我建议你亲手跑一遍,输出会是:
code复制script start
executor start
executor end
script end
then1: 1
then2: 2
catch: boom
每一步的原因如下:
script start同步打印。- 创建 Promise 时,执行器函数立即执行,打印
executor start;调用resolve(1)后状态变为 fulfilled,但当前还没有任何回调注册,所以不会立刻执行 then 回调。 - 执行器继续走到
console.log('executor end'),打印。 - 执行
p.then(...),此时 Promise 已经 fulfilled,但这个 then 回调只是被加入微任务队列,不会立刻执行。 - 继续执行
console.log('script end')。 - 当前宏任务结束,开始处理微任务:先执行 then1 回调,打印
then1: 1,返回2作为新 Promise 的 value。 - 接着取到
then2对应的回调,打印then2: 2,然后throw new Error('boom'),导致新 Promise 变成 rejected。 - 链上的
catch捕获到这个 reason,打印catch: boom。
3.2 Promise.resolve 与 Promise.reject 的立即决议特性
Promise.resolve(x) 会返回一个处于 fulfilled 状态的 Promise。但这里有个容易被忽略的流程细节:如果 x 本身是一个 Promise,Promise.resolve(x) 会直接返回 x,而不是新建一个。也就是说,后续的 .then() 会订阅 x 的状态,执行流程完全由 x 决定。
例如:
javascript复制const p1 = Promise.resolve();
const p2 = Promise.resolve(p1);
console.log(p1 === p2); // true
Promise.reject(reason) 返回一个 rejected Promise,并且严格回传 reason。它跟 new Promise((_, reject) => reject(reason)) 等价,其中执行器同步执行。如果你在业务代码里用 Promise.reject 来提前中断流程,一定要记得接收它,否则很容易出现 uncaught (in promise)。
3.3 async/await 是 Promise 的语法糖,但有其特殊性
async 函数一定会返回一个 Promise。你在 async 函数里返回一个普通值,这个值会被包装成 fulfilled Promise;如果你在函数里 throw new Error(),返回的 Promise 会变成 rejected。await 表达式则相当于把后面代码切成一个 then 回调来执行。
例如:
javascript复制async function test() {
console.log('before await');
const result = await Promise.resolve(42);
console.log('after await', result);
}
console.log('start');
test();
console.log('end');
输出顺序是:
code复制start
before await
end
after await 42
原因是 test() 被调用后,函数内部从第一行开始同步执行,打印 before await;遇到 await,右侧的 Promise.resolve(42) 已经 fulfilled,await 会把后续代码“挂起”,注册为一个微任务;此时 async 函数返回一个 Promise,主线程继续执行 test() 之后的 console.log('end');等到微任务阶段,才恢复执行 after await 42。
这里有一个关键区别:很多人以为 await 会阻塞主线程,其实不会,它只是把流程切走。在 forEach 里用它尤其容易踩坑:
javascript复制[1, 2, 3].forEach(async (item) => {
await delay(100);
console.log(item);
});
这个写法不会按顺序打印 1、2、3,因为 forEach 不会等 async 回调完成就返回了。三个异步操作是并发开始的,最终打印顺序不确定。如果想串行执行,用 for...of:
javascript复制for (const item of [1, 2, 3]) {
await delay(100);
console.log(item);
}
这同样属于 Promise 执行流程的一部分。明白了 async/await 只是 then 链的语法糖,你就能解释为什么上面 forEach 是并发了:因为每个 async 回调都是独立的一条 Promise 链,而 forEach 并不等待 Promise 结果。
4. 常见“uncaught (in promise)”经典场景与排查清单
现在我们回到文章开头那几种报错。它们看着各不相同,本质都是“Promise 执行流程中出现了 rejected,但没有被捕获”。我整理了几个高发场景,每个都给出排查思路和代码层面的解法。
4.1 没有 .catch 的异步请求:网络失败、证书错误
小程序里常见的报错:
code复制Uncaught (in promise) MiniProgramError {"errmsg":"request:fail net::ERR_CERT_AUTHORITY_INVALID"}
这是请求接口时网络栈抛出的证书校验失败。wx.request 返回的 Promise 被 reject 了,但外层代码没有捕获。可能的原因包括:
- 后端证书链不完整,真机上无法通过系统校验;
- 小程序后台配置的合法域名与实际请求域名不一致;
- 本地开发时用了自签名证书,且没有开启“不校验合法域名”的开关;
- 代理工具拦截了 HTTPS 流量,导致证书被替换。
排查时,先别急着改代码,用 curl 或浏览器访问目标接口,看证书链是否正常。如果证书本身有问题,加了 catch 也只是不报红字,但业务逻辑照样失败。正确做法是:
javascript复制wx.request({
url: 'https://api.example.com/data',
method: 'GET'
})
.then(res => {
// 处理响应
})
.catch(err => {
console.error('请求失败', err);
// 提示用户或降级处理
});
fetch 也一样,网络错误、跨域、HTTP 状态码 5xx 都可能让 Promise 进入 rejected。如果只调用 fetch(url) 而不 await 或 .catch(),浏览器控制台大概率就会出现 “Uncaught (in promise) TypeError: Failed to fetch”。
4.2 浏览器自动播放策略:play() failed because the user didn't interact
前面提过的视频播放报错,本质是浏览器自动播放策略。HTMLMediaElement.play() 返回一个 Promise,如果播放被拒绝,它会 reject 一个 NotAllowedError。解决方案有两个层面:
第一,在可能失败的调用上明确处理:
javascript复制async function playVideo(videoEl) {
try {
await videoEl.play();
} catch (err) {
console.warn('自动播放被阻止', err.name);
// 可以显示一个引导点击的浮层
}
}
第二,从产品交互上处理:确保 play() 是直接由用户点击事件触发的,不要包在 setTimeout 或异步流程之后。如果必须在异步回调里播放,最好先请求用户手势。比如:
javascript复制document.querySelector('#play-btn').addEventListener('click', () => {
video.play().catch(err => {
// 如果这里还失败,说明策略有额外限制
});
});
我曾见过一个项目,业务在 Promise 回调里创建了一个 new Audio() 并播放,总是报 NotAllowedError。原因是 Promise 回调里的执行上下文已经脱离了用户手势的触发链。浏览器判定是否“用户交互”时,不会追溯太久远的异步链。这种问题不是改 Promise 写法能解决的,要让播放动作直接发生在事件监听器同步调用期间。
4.3 更新循环问题:maximum recursive updates exceeded
在 Vue 或 React 项目里,你可能会碰到:
code复制Uncaught (in promise) Maximum recursive updates exceeded.
这通常出现在 Promise 回调里触发状态更新,而状态更新又立即触发同一个 Promise 回调或者渲染,形成递归循环。举例,一个 React 组件在 useEffect 中调用了异步请求,回调里 setState,而 render 过程中又发起新的请求,导致超过最大递归深度。代码层面可能长这样:
javascript复制useEffect(() => {
fetchData().then(data => {
setData(data); // 触发重新渲染
// 渲染后又重新执行 effect,再请求,再 setState...
});
}, [data]); // 依赖项导致循环
这里的根因不是 Promise 本身,而是你把“数据”放进了 effect 依赖,请求结果又更新了这份数据。排查方法:
- 检查状态更新的依赖项;
- 在 Promise 回调里设置递归更新的条件;
- 使用中止控制器(如 AbortController)打断无效请求。
其实这个报错和 Promise 执行流程的关联在于:then 回调是在微任务队列中执行的,时间和函数调用栈已经和原来的渲染周期分离。你在 then 回调里写入状态,渲染引擎会把它当作一次新的调度,如果调度之间互相触发,就容易出现递归深度限制。
4.4 扩展程序通信断开:could not establish connection. receiving end does not exist
Chrome 扩展开发中,chrome.runtime.sendMessage 返回 Promise 后,如果消息接收端不存在,就会被 reject:
code复制Uncaught (in promise) Error: Could not establish connection. Receiving end does not exist.
场景通常是:你的扩展页面还在,但 background service worker 已经休眠或卸载,或者 content script 没有注入到当前页面。解决方法是:
javascript复制try {
const response = await chrome.runtime.sendMessage({ type: 'fetch_data' });
// 处理 response
} catch (err) {
console.warn('消息发送失败,接收端可能不存在', err.message);
// 降级处理:重试、提示扩展已失效等
}
另外也可以在发送前判断 chrome.runtime.id 是否存在,但最稳妥的还是 catch。这个报错进一步证明:只要代码里使用了基于 Promise 的 API,就必须考虑 reject 分支。
5. 如何保证 Promise 执行流程可控:实战建议与工具
理解了报错,我们来谈怎样在项目里让 Promise 流程大体可控。我经常跟团队讲:不要依赖记忆力去记住每一条异步链路,要依赖模式和工具。
5.1 给每个 Promise 链一个兜底 catch
这个建议听起来像废话,但执行起来往往不到位。一个最常见的缺失场景是:在函数里 return new Promise(...),调用方可能忘记接收返回值,也可能接收了但只写了 then 没写 catch。更稳妥的做法是:所有对外暴露的 Promise,在函数内部就统一处理好错误,不要在边缘角落里静静 reject。
例如:
javascript复制function loadUser() {
return api.get('/user')
.then(data => {
return { ok: true, data };
})
.catch(err => {
console.error('loadUser failed:', err);
return { ok: false, error: err };
});
}
这种“永不抛出”的模式可以避免未处理 rejection。当然,有时候我们希望错误被上层知道,那可以继续 throw,但一定要有某个调用方负责 catch。
5.2 使用 async/await + try/catch 简化流程
async/await 让代码看起来更接近同步,但底层仍是 Promise。用 try/catch 包裹 await,可以捕获该条 Promise 链上同步抛错和异步 reject 的大部分情况:
javascript复制async function fetchData() {
try {
const response = await fetch('/api/data');
const json = await response.json();
return json;
} catch (err) {
// 这里能捕获 fetch reject、json 解析错误
throw new Error('数据加载失败', { cause: err });
}
}
不过要注意,try/catch 只能捕获 await 之后的 rejected Promise。如果你把 Promise 变量先存下来,再在别处 await,漏网可能性会高一些。写代码时尽量让 await 和 try/catch 的距离近一点,方便审查。
5.3 用 Promise.all / allSettled / race 管理并发执行流程
多个异步请求并行时,Promise.all 是常用方案,但它有一个特性:任何一个子 Promise reject,整体立刻 reject,而且不会等其余请求完成。这在执行流程上可能造成“一半成功、一半失败”的混乱。
如果你要拿到所有结果并各自处理错误,用 Promise.allSettled:
javascript复制const results = await Promise.allSettled([
fetch('/a'),
fetch('/b'),
fetch('/c')
]);
for (const result of results) {
if (result.status === 'fulfilled') {
console.log(result.value);
} else {
console.log(result.reason);
}
}
Promise.race 则适合超时控制:
javascript复制function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时')), ms);
});
return Promise.race([promise, timeout]);
}
这里 setTimeout 的回调是宏任务,Promise 的 reject 在到达超时时间后触发,这条链会变成 rejected。使用 withTimeout 后,外层调用方必须 catch,否则又会出现 uncaught (in promise)。
5.4 调试工具:DevTools、断点、未捕获异常处理
排查 Promise 错误时,首先要利用浏览器 DevTools 的 “Pause on exceptions”。在 Sources 面板右侧开启暂停,可以定位到 rejected 的具体位置。但注意,有些浏览器默认只会暂停在“未被捕获的异常”,如果你已经加了 catch,它不会暂停,这有时反而是好事。
另外,注册全局的 unhandledrejection 事件能帮你兜底观察:
javascript复制window.addEventListener('unhandledrejection', event => {
console.error('未处理的 Promise rejection:', event.reason);
event.preventDefault();
});
在 Node.js 里等价的是:
javascript复制process.on('unhandledRejection', (reason, promise) => {
console.error('unhandledRejection:', reason);
});
这个小程序里也有对应的 API:wx.onUnhandledRejection 或 uni.onUnhandledRejection,可以在后台收到未处理 rejection 的日志。我建议在项目里统一写一个这样的监听器,至少能在出错时留存现场,而不是等到用户报告问题靠猜。
6. 一些琐碎但重要的经验
最后分享几个我在实际项目中总结的零散经验,每个都踩过坑。
第一,Promise 执行器里的 “try/catch” 不是万能的。执行器是同步执行的,能捕获同号代码的异常,但无法捕获后续 then 回调里抛出的错误。如果你希望整个 Promise 链的错误都在入口统一处理,应该把 catch 放在最末端,或者使用 async 函数配合 try/catch。
第二,finally 回调里不要 return rejected Promise。虽然 finally 会等待返回的 Promise,但如果这个 Promise 被 reject,会成为最终 rejected 的原因。在某些场景下,这会导致原来 fulfilled 的 Promise 变成 rejected,进而触发 unhandledrejection。如果 finally 里只是清理资源,尽量使用同步代码。
第三,区分“未处理 rejection”和“已处理但想打日志”。很多人喜欢在 catch 里 console.error 后继续 throw,如果上层没有 catch,就会造成两个效果:日志打了一遍,控制台又报 uncaught (in promise)。要决定一条 Promise 链的“终点”是谁,不要让错误在链上到处飘。
第四,不要在小程序或低代码环境里依赖全局捕获。我之前遇到一个场景,小程序基础库在某些版本下不会显示完整的报错堆栈,只有 errMsg。后来通过 wx.onUnhandledRejection 手动打日志,才拿到完整调用链。所以全局兜底务必尽早接入。
这篇文章从执行流程讲到真实报错排查,希望你能把 Promise 当作一台有明确规则的状态机来看待。每次看到 “Uncaught (in promise)”,先别急着往外层套 catch,而是问一句:这条 Promise 链现在的状态是什么?走到哪个环节断了? 当你习惯用执行流程的视角去分析这些问题,异步代码的调试速度会快很多。
