先问一个问题:你有没有在控制台里见过这行红字——Uncaught (in promise) TypeError: Cannot read property 'x' of undefined,然后整个人瞬间清醒过来?我猜大概率见过。做前端这几年,Promise 相关的报错几乎是我见过最多的运行时错误,没有之一。尤其是在从回调函数风格转向 async/await、从零开始搭项目接口层的时候,Promise 基础不扎实,后面全是坑。
这篇文章我按“JS 学习手册第六篇”的节奏来聊 ES6 里的 Promise,内容覆盖核心状态机、执行器原理、链式调用、并发控制、错误捕获、async/await 配套玩法,还有一堆我实际踩过的坑和排查技巧。适合三种人看:刚学完 ES6 语法、想彻底搞懂 Promise 的新手;写了很久 then 但说不清内部机制的进阶开发者;以及想在项目里重构异步代码、减少线上异常的前端工程师。你可以把它当一篇可以反复翻的笔记,遇到 Uncaught (in promise) 的时候再回来看一眼。
1. 为什么需要 Promise:从回调地狱到标准化异步
1.1 回调函数时代的真实痛点
2015 年之前,JavaScript 处理异步基本靠回调函数。简单场景还行,比如一次性请求接口:
javascript复制function getUser(callback) {
setTimeout(() => {
callback({ name: '张三', id: 1 });
}, 1000);
}
getUser((user) => {
console.log(user.name);
});
问题出在嵌套。用户详情、然后拉他的订单、订单里再拉商品信息,代码会变成这样的金字塔:
javascript复制getUser((user) => {
getOrders(user.id, (orders) => {
getProducts(orders[0].productId, (product) => {
getStock(product.id, (stock) => {
// 五层缩进,看的人都麻了
});
});
});
});
这个结构当时被戏称为“回调地狱”。它真正的痛苦不只是难看,而是每个函数都在抢占变量名、错误处理必须一层层手动传、流程控制完全靠约定、一旦哪层忘写回调就静默失败。你很难直接看出来这段异步逻辑执行顺序到底是怎样的,更不能轻易对某一步做复用。
还有个更隐蔽的问题:控制权反转。你把回调函数传给第三方库,那这个函数什么时候调用、调用几次、传什么参数,完全由对方决定。如果这个库不遵守约定,你的代码就像寄养的孩子,生死由人。
1.2 Promise 想解决什么问题
Promise 不是用来消灭异步的,它消灭的是异步逻辑里的控制权离散和代码嵌套。它的核心思想是:把异步操作抽象成一个状态机对象,这个对象在三态之间流转,调用方可以通过统一接口订阅结果。
用 Promise 改写上面的嵌套:
javascript复制function getUser() {
return new Promise((resolve) => {
setTimeout(() => resolve({ name: '张三', id: 1 }), 1000);
});
}
getUser()
.then((user) => getOrders(user.id))
.then((orders) => getProducts(orders[0].productId))
.then((product) => getStock(product.id))
.then((stock) => console.log('当前库存:', stock));
缩进从五层拉平了一层,执行顺序从左到右清晰可读,每个 .then 都拿到上一步的返回值,逻辑像流水线一样串起来。最关键的是 Promise 的链式结构天然支持“短路”:任何一个环节抛错,后面的 .then 不会被错误地执行,而是直接跳到 .catch,错误处理从手动传递变成了自动冒泡。
这就是 Promise 带来的范式变化:异步代码从“嵌套 + 回调”变成了“链式 + 状态”,它还有一份官方标准(Promise/A+),所有现代 JavaScript 运行时都实现同一套行为规范,这也是你能在浏览器和 Node.js 里无差别使用它的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Promise 核心机制:三种状态与执行器函数
2.1 状态机基础:pending、fulfilled、rejected
Promise 的内部状态只有三种:
| 状态 | 含义 | 是否可转换 |
|---|---|---|
pending |
进行中,异步操作还没完成 | 可转换为 fulfilled 或 rejected |
fulfilled |
已成功,有终值 value | 不可再变 |
rejected |
已失败,有原因 reason | 不可再变 |
这玩意很像一个只能单向切换的开关:pending 是初始态,一旦变为 fulfilled 或 rejected,就永远停留在那个状态。你不可能让一个已经成功的 Promise 再失败,也不可能让一个失败的 Promise 再成功。所以 Promise 特别适合表达“一次性结果”的异步操作,比如接口请求、文件读取、定时任务。
状态为什么不能撤销?因为 Promise 要保证结果的一致性。如果同一个 Promise 既能成功又能失败,那多个订阅者 .then() 拿到的结果就可能不一样,整个异步链就会崩掉。状态一旦确定,后续所有 .then 注册的回调都会拿到同一个值,这正是确定性带来的安全感。
2.2 executor 执行器与 resolve/reject 的关系
写 Promise 时最常看到的代码是 new Promise((resolve, reject) => { ... }),这个被传入的函数叫 executor(执行器函数)。很多人不理解这个函数和 Promise 实例是什么关系,简单说:executor 是同步执行的,它启动异步任务;resolve 和 reject 是运行时给你的两个“状态切换开关”,调用 resolve(value) 就把 Promise 变成功,调用 reject(reason) 就把 Promise 变失败。
看一个容易忽略的细节:
javascript复制console.log('脚本开始');
const promise = new Promise((resolve, reject) => {
console.log('executor 同步执行');
setTimeout(() => {
resolve('异步完成');
}, 1000);
});
console.log('脚本结束');
promise.then((value) => console.log('拿到结果:', value));
输出顺序是:
text复制脚本开始
executor 同步执行
脚本结束
拿到结果:异步完成
executor 里 setTimeout 之后的 console.log 会立即执行,因为它本来就在同步代码段里。resolve 是在 1 秒后才被调用的,所以 promise.then 注册的回调也在 1 秒后才进微任务队列执行。这个顺序理解清楚了,就能解释很多“为什么我 console.log 位置不对”的诡异现象。
被很多人忽略的一点:executor 里如果抛出异常,Promise 会自动变成 rejected 状态。
javascript复制new Promise((resolve, reject) => {
throw new Error('执行器内报错');
}).catch((err) => {
console.log('捕获到:', err.message); // 输出:捕获到:执行器内报错
});
所以 executor 内部不需要手动包一层 try/catch 再调 reject,异常抛出去就会被 Promise 接住。
2.3 状态不可逆与“竞态过期”的经典问题
状态不可逆带来的一个工程问题是:异步请求发出后,如果用户已经跳转页面或切换了筛选条件,那么旧请求回来的结果已经没意义了,但 Promise 还是会 resolve。你并不能“取消”一个 Promise,ES6 里也没有原生的取消机制。
我早期做搜索功能时踩过这个坑:用户输入关键词,每次输入都发请求,结果慢的旧请求后返回,把新结果覆盖了。后来用“请求序号 + 一个递增计数器”来解决,在回调里判断当前请求是不是最新序号,不是就直接丢弃。这个模式在 Promise 时代依然适用:
javascript复制let requestIndex = 0;
function search(keyword) {
const currentIndex = ++requestIndex;
return fetchSearch(keyword).then((result) => {
if (currentIndex !== requestIndex) {
// 过期结果,直接忽略
return { ignored: true, data: null };
}
return { ignored: false, data: result };
});
}
想真取消 Promise 也不是完全没辙,可以用 AbortController 配合 fetch 把网络请求真正中断掉,但这属于额外话题。你需要记住的是:Promise 本身不问“结果是否还有用”,它只负责把结果给你,判断“有没有用”是你自己的活儿。
3. 基础用法与链式调用:then、catch、finally
3.1 then 的两个回调参数
then 方法接收两个函数,第一个处理 fulfilled,第二个处理 rejected:
javascript复制promise.then(
(value) => console.log('成功:', value),
(reason) => console.log('失败:', reason)
);
但更常见的做法是只用第一个回调,然后在链尾挂 .catch。这样写的好处是链上任意一环的失败都会被收口到同一个地方。如果你在 then 里写第二个回调,那它只能捕获当前 Promise 的失败,捕获不到链上更早的失败,两个回调并存时语义容易混乱。
还需要注意一点:.then 和 .catch 都会返回一个新的 Promise。这意味着链式调用不是“在同一个 Promise 上追加”,而是每次都产生新对象。所以你在链式里写了 Math.random() 这种操作,它是在每次调用 .then 时同步执行的,不在一个共享状态里。
3.2 链式调用中值传递与 Promise 的“拍平”
链式调用的核心是值传递:上一个 .then 里 return 的任意值,都会作为下一个 .then 的参数。如果返回的是一个普通值,就直接传给下一个 .then;如果返回的是一个 Promise,那下一个 .then 会等待这个 Promise 落定后再执行。
这里的“等待”是很关键的行为,规范叫 promise resolution procedure,通俗说就是 Promise 会“递归展开”你 return 的 Promise,直到变成一个普通值。看代码:
javascript复制Promise.resolve(1)
.then((value) => {
console.log(value); // 1
return Promise.resolve(2);
})
.then((value) => {
console.log(value); // 2,虽然是 Promise.resolve(2),但被拍平成 2 了
return new Promise((resolve) => setTimeout(() => resolve(3), 1000));
})
.then((value) => {
console.log(value); // 3,等 1 秒后才执行
});
这个拍平特性非常实用,它让异步嵌套变成了数据流管道。你在 .then 里既可以同步加工数据,也可以发起新异步任务,后面的人完全不用关心这一步到底是同步还是异步。
3.3 catch 与 finally 的位置和语义
很多人以为 .catch 必须放在最后,其实不一定,它只是一个 .then(null, rejectHandler) 的语法糖。它捕获的是它前面那一串 Promise 链中的任意 rejection。如果你在 .catch 之后再挂 .then,那个 .then 可以拿到 .catch 里 return 的兜底值,相当于异常恢复。
javascript复制fetchData()
.then((data) => transform(data))
.catch((err) => {
console.error('出错了,用默认值兜底', err);
return { list: [] }; // 恢复链条
})
.then((data) => {
console.log('这里能拿到 list 是空数组的兜底值');
render(data);
});
.finally 是 ES2018 引入的,但因为它和 Promise 高度绑定,通常学习 Promise 时一起讲。它的语义是“不管成功失败都会执行”,适合做 loading 关闭、按钮恢复、日志记录。注意 .finally 的回调不接收任何参数,它只管“收尾”,不会改变链上传递的值。还有一点:如果 .finally 回调里返回 Promise,那它会等待那个 Promise 完成再继续,这可以用来在关闭 loading 前做一点延迟动画。
常见误用是在 .finally 里 return 一个普通值,这是没用的,它不会把值传给下一个 .then,下一个 .then 拿到的依旧是 .finally 前一个 Promise 的结果。想覆盖结果请在 .catch 或 .then 里做。
3.4 手写一个极简版 Promise 来理解内部流转
把底层逻辑走一遍,很多模糊的地方就清晰了。一个最简化但能跑通核心场景的 Promise 是这样的:
javascript复制class MyPromise {
constructor(executor) {
this.status = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.status === 'pending') {
this.status = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach((fn) => fn());
}
};
const reject = (reason) => {
if (this.status === 'pending') {
this.status = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach((fn) => fn());
}
};
try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}
then(onFulfilled, onRejected) {
if (this.status === 'fulfilled') {
onFulfilled(this.value);
}
if (this.status === 'rejected') {
onRejected(this.reason);
}
if (this.status === 'pending') {
this.onFulfilledCallbacks.push(() => onFulfilled(this.value));
this.onRejectedCallbacks.push(() => onRejected(this.reason));
}
}
}
这个版本没实现链式和拍平,但足以说明三件事:executor 同步执行;resolve/reject 只改状态并触发回调;then 里注册的回调在状态已确定时立即执行,在 pending 时先存起来等状态确定后再执行。原生 Promise 的微任务调度、值拍平、错误冒泡,都是在此基础上的增强。理解了这张底图,后面看 async/await 也顺了。
4. 并发场景四兄弟:all、race、allSettled、any
4.1 四个静态方法的对比与适用场景
ES6 只提供了 Promise.all 和 Promise.race,但实际项目中 allSettled 和 any 也超常用,这里一起列出来:
| 方法 | 触发成功 | 触发失败 | 典型场景 |
|---|---|---|---|
Promise.all |
所有 Promise 都 fulfilled,返回结果数组 | 任一个 rejected,立即进入拒绝状态 | 并行请求多个接口,全部成功才能渲染 |
Promise.race |
任意一个先落定(无论成功失败) | 任意一个先落定(同上) | 超时控制、对多个数据源择优 |
Promise.allSettled |
所有 Promise 都落定(含成功和失败),返回结果数组 | 永不触发失败 | 不关心个别失败,只要全部执行完 |
Promise.any |
任意一个成功,返回第一个成功结果 | 所有都失败,才进入拒绝状态 | 多个备用方案,哪个成功用哪个 |
Promise.all 的“满足需求但有小坑”是:数组里只要有一个元素不是 Promise,它会被 Promise.resolve 包装成 Promise。所以你传一个 [1, 'a', Promise.resolve(2)] 也能正常出结果。另外它返回的结果数组顺序和输入顺序一致,不解析完成速度,这是经常让人惊喜又困惑的地方:先完成的不一定在数组前面,位置是固定的。
Promise.race 的名字有误导性,它其实不是“谁先成功”,而是“谁先落定”。也就是说如果某个 Promise 先失败了,race 也会立即进入 rejected 状态,不管后面有没有人成功。
4.2 并发请求中的实战写法
最常见的是同时拉取基础信息和用户信息:
javascript复制const [baseInfo, userInfo] = await Promise.all([
fetch('/api/base-info'),
fetch('/api/user-info'),
]);
render(baseInfo, userInfo);
并行和串行的区别很明显:串行总耗时为所有请求耗时之和,并行耗时接近最慢的那个请求。我在业务里经常遇见的一个问题是:“为什么我用了 Promise.all,页面还是要等 3 秒?”排查下来,发现是多个接口里有一个特别慢,Promise.all 本质是木桶原理,最慢的那个就是总耗时。这时候如果某个接口不关键,可以把它拆出去,或者用 allSettled 加上“部分成功也渲染”的策略。
还有一个工程习惯:用 Promise.allSettled 处理批量上传后,即使个别文件失败,也能拿到完整状态列表:
javascript复制const results = await Promise.allSettled(files.map((file) => uploadFile(file)));
const successList = results
.filter((item) => item.status === 'fulfilled')
.map((item) => item.value);
const failedList = results
.filter((item) => item.status === 'rejected')
.map((item) => item.reason);
status 为 fulfilled 时有 value,为 rejected 时有 reason,这个结构在 Node.js 的 util.promisify 场景和前端文件上传场景里都非常直观。
4.3 用 race 做超时控制与主动 abort
race 最经典的用途是给请求加超时:
javascript复制function withTimeout(promise, timeout = 5000) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时')), timeout);
});
return Promise.race([promise, timeoutPromise]);
}
try {
const data = await withTimeout(fetch('/api/slow'), 3000);
console.log(data);
} catch (err) {
console.error(err.message); // 请求超时
}
但这里有个残留问题:race 只是“赛跑”,它不会真的把慢请求停掉。超时后那个 fetch 请求其实还在飞,等它返回时 Promise 状态已经没意义了,可能会在控制台留下一条 Uncaught (in promise) 警告。要根治,得真正调用 AbortController 把 fetch 取消:
javascript复制function fetchWithTimeout(url, timeout = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
}
AbortController 是 DOM 标准的一部分,浏览器和 Node 17+ 都支持,但它能取消的是 fetch 这类“听话”的 API,对 promise 化的定时器、事件监听这种没法直接取消。所以多数情况下,race 配合“丢弃过期结果”的思路依然是保底方案。
5. Promise 与 async/await:语法糖背后的同一套逻辑
5.1 async 函数就是返回 Promise 的函数
ES2017 加入的 async/await 让异步代码看起来像同步代码,但它并没有脱离 Promise 体系。可以这么记:async 函数一定会返回一个 Promise,await 会暂停函数执行,直到后面的 Promise 状态落定,然后拿到 resolve 的值;如果是 rejected,它会在当前位置抛出一个异常。
javascript复制async function loadUser() {
const user = await fetch('/api/user');
return user; // 返回的其实是一个 Promise
}
const result = loadUser(); // result 是一个 Promise
result.then((user) => console.log(user));
你甚至可以完全用 .then 重写 async 函数:
javascript复制function loadUser() {
return fetch('/api/user').then((user) => user);
}
两者几乎等价。理解这个等价关系很重要,因为一旦你混用两种风格(比如 async 函数里忘记 await,或者 await 一个非 Promise 值),代码依然能跑,但结果可能完全不是你预期的那样。
await 一个非 Promise 值时,会发生类似于 Promise.resolve(值) 的包装,所以 await 3 会先被包装成成功 Promise,然后立即取回 3。这个行为是隐式的,新手往往不会注意到它,但在调试一些“明明有值却进不了下一行”的问题时,兜一圈回来会发现根本不是 await 的问题,而是前面的异步逻辑卡住了。
5.2 async/await 的错误处理模式
async/await 风格下最稳妥的错误处理是 try/catch:
javascript复制async function init() {
try {
const user = await fetchUser();
const orders = await fetchOrders(user.id);
render(orders);
} catch (err) {
showError(err);
}
}
这条 try/catch 捕获的是整个 async 函数内所有 await 表达式的 rejection,包括第一个请求失败、第二个请求失败、render 内部抛错。它跟 .catch 的“短路”行为是一致的:一旦任何一个 await 抛错,后面的代码不再执行,直接跳到 catch。
有一个容易踩的坑:在 forEach 里用 await 并不会按你想要的方式串行执行。
javascript复制// 错误写法:看起来是循环 await,实际并发触发
[1, 2, 3].forEach(async (id) => {
await fetch(`/api/detail/${id}`);
});
// 正确写法:用 for...of 逐个等待
for (const id of [1, 2, 3]) {
await fetch(`/api/detail/${id}`);
}
原因是 forEach 的回调函数是独立的 async 函数,它里面的 await 只等待自己这个函数体,外部循环根本不等它,所以三个请求几乎同时发出。如果业务上没有顺序依赖,这反而是“并行”的;如果要串行就必须用 for...of 或者 reduce 搭建 Promise 链。这个细节在面试里几乎必考,项目里也容易犯。
5.3 混用 .then 与 async/await 时的语义漂移
在同一个项目里两种风格共存是正常现象,但要注意语义漂移。我见过这样的代码:
javascript复制async function getData() {
const data = await fetchData();
return data;
}
function init() {
getData().then((data) => render(data));
}
这没问题,因为 async 函数返回 Promise。但如果你在一个已经拿到 Promise 的变量上又包了一层 async 函数,或者反过来在 .then 里返回 Promise,很多人会开始纠结“到底要不要再 await 一次”。我的原则是:在同一个函数内只选一种风格。如果函数已经写成 async/await,就全部用 await + try/catch;如果是从旧代码里接 Promise,就用 .then 链。混着写容易出现“位置对了但时机不对”的隐性 bug,排查成本比统一风格高好几倍。
还有一个细节:await 只能在 async 函数内使用。如果要在模块顶层用 await,要么在支持 top-level await 的环境里(现代浏览器和 Node 14+ 可以),要么包一层 async function main() {} 再调用。新手经常在这个限制上报错,提醒一下:这是个语法限制,不是运行时 bug。
6. 错误处理与真实踩坑排查实战
6.1 最常见的 Uncaught (in promise) 是怎么来的
一个 Promise 变成 rejected 后,如果没有任何 .catch 或 .then 的第二个回调来“消费”这个 rejection,浏览器就会抛出 Uncaught (in promise)。这个报错不是“代码运行错误”本身,而是“你漏处理了失败”的提示。
热词里那些 Uncaught (in promise) NotAllowedError: play() failed because the user didn't interact with the document first 就是典型。浏览器限制:没有用户手势(点击等)时,不能自动播放音频或视频。你调用 video.play() 会返回一个 Promise,如果被拒绝,你没有 .catch,控制台就报 Uncaught (in promise)。解决办法是加 play().catch(() => {}) 或者等用户点击后再触发播放。
我自己的习惯是:所有返回 Promise 的 API 都默认在链上挂一个 .catch 或包一层 try/catch,哪怕你觉得“这里不可能失败”。因为 Promise 失败的原因往往不在你的预期里,可能来自网络中断、JSON 解析错误、权限不足、服务端 500。你漏掉一个,线上就会多一条红色堆栈。
下面是一个常见的错误处理对照表:
| 报错形式 | 原因 | 处理方式 |
|---|---|---|
Uncaught (in promise) TypeError |
Promise 内部抛出了 TypeError,外界未捕获 | 在链上补 .catch,或 async 函数里包 try/catch |
Uncaught (in promise) NotAllowedError |
请求被浏览器策略拒绝(如播放、通知权限) | 检查是否满足浏览器交互要求,并处理 rejection |
Uncaught (in promise) AbortError |
fetch 主动或被动中止 | 判断 err.name === 'AbortError' 后静默处理 |
rawAxiosError ... request failed with status code |
HTTP 请求失败后 Axios 默认 reject | 在请求拦截器或调用处统一处理 |
6.2 axios 请求错误与 Promise 的组合拳
现在很多项目用 axios,axios 的 post/get 返回的本身就是 Promise。所以 axios 请求失败后,如果调用处没有 .catch,同样会报 Uncaught (in promise) AxiosError。
我的项目里通常会在封装请求层时统一处理一次错误,避免每个页面重复写错误提示:
javascript复制async function request(config) {
try {
const response = await axios(config);
return response.data;
} catch (err) {
const message = err.response?.data?.message || '网络异常,请稍后重试';
showToast(message);
throw err; // 仍然继续抛,让调用方知道“请求失败了”
}
}
注意这里 throw err 很重要。很多人把错误提示写在封装层,然后调用方以为“已经处理了”就不再加 .catch,结果线上只剩提示没有报错堆栈。问题不在于报错,而在于你无法定位是哪一行发出的请求。统一抛错 + 调用方决定是否继续处理,是我用过的最舒服的层级关系。
6.3 从“深拷贝”延伸出的 Promise 误解
热词里有“es6 深拷贝”,这里顺便聊一个非常常见的认知错位:很多刚学完 ES6 的同学会把 Promise 跟深拷贝混在一起,甚至以为 Promise.resolve 是“深拷贝一个对象”。其实完全不是,Promise.resolve 是把一个值包装成成功状态的 Promise,它的作用是同步/异步代码统一化,跟拷贝没有任何关系。
可能产生混淆的原因是 Promise.resolve 的参数可以是一个 Promise,也可以是一个对象。如果你传一个普通对象,它不会复制对象,只是把这个对象作为 value 存在 Promise 内部。如果你传一个 Promise,它会直接复用那个 Promise,而不是新建。所以拿它做拷贝是行不通的。
深拷贝要的是 structuredClone、JSON.parse(JSON.stringify(x)) 或 lodash.cloneDeep,那是另一个话题。Promise 管的是“同步/异步的时间关系”,不是“数据复制”。
6.4 微任务、事件循环与 Promise 回调执行时机
Promise 的回调不是宏任务,而是微任务。这意味着它的执行时机是在当前宏任务结束、渲染前。看一个经典问题:
javascript复制console.log('a');
setTimeout(() => console.log('b'), 0);
Promise.resolve().then(() => console.log('c'));
console.log('d');
// 输出顺序:a d c b
原因:setTimeout 的回调放进宏任务队列,Promise.then 放进微任务队列;当前同步代码执行完后,先清空微任务,再取下一个宏任务。所以 c 一定在 b 之前。这个顺序理解以后,你会发现很多“为什么接口数据还没渲染就用到了”的问题,本质是时序没搞清楚。
我在实际开发中会在“Promise.resolve().then(...)”这种写法出现时停下来想一想:能不能直接同步执行?不是所有地方都需要包 Promise。如果是为了把一段逻辑放到“当前同步代码执行完之后”再跑,那可以用 queueMicrotask 或 setTimeout(0),但不要无脑 Promise.resolve().then,它会让代码多一层不可读的异步包裹。
6.5 一个排查 Promise 链的实用方法:手动给 then 补日志
遇到一条很长的 Promise 链,不知道是哪一步出了问题,最直接的办法不是埋头看代码,而是在每个 .then 里临时插入一个日志函数:
javascript复制fetchUser()
.then((user) => {
console.log('step1 user', user);
return fetchOrders(user.id);
})
.then((orders) => {
console.log('step2 orders', orders);
return fetchProducts(orders[0].productId);
})
.catch((err) => {
console.error('出错步骤:', err);
});
从日志输出就能精确定位卡在哪一步、哪一步的数据不符合预期。排查完再把日志删掉。这比单步调试快得多,尤其适合后端配合压力测试时出的那些“偶发数据缺失”问题。
我也习惯把关键接口的入参和出参打进独立的日志服务,线上出问题的时候不用去猜 uncaught (in promise) 背后的上下文。很多 bug 不是逻辑不对,而是数据不对,而已。
7. 从手写 Promise 到项目规范:几个长期有效的建议
如果你已经能看到这里,大概率已经把 Promise 当成了日常工具。最后分享几个我坚持了几年的项目规范,都是从真实事故里总结出来的。
第一个规范:组件卸载后发起的异步请求,回调里不要直接更新状态。React/Vue 都有这个坑,页面都关了,请求回来了,你还在 setState 或修改响应式数据。Promise 本身不能取消,所以要在“回调执行前”加一个卸载标记:
javascript复制useEffect(() => {
let isUnmounted = false;
fetchData().then((data) => {
if (!isUnmounted) {
setState(data);
}
});
return () => {
isUnmounted = true;
};
}, []);
这跟“状态不可逆”不矛盾,Promise 只是不知道你卸载了,它仍然 resolve,你需要自己“忽略”结果。这个手动标记模式在任何框架里都通用。
第二个规范:每个链式调用都尽量以 .catch 收尾,不要裸奔。哪怕你在 .then 里只用到一个成功分支,最后也挂一个 .catch。这不是多余,是“兜底防线”。同理,async/await 风格里,顶层异步函数入口处必须有一层 try/catch,防止异常直接变成 Uncaught (in promise)。
第三个规范:用语义化函数封装 Promise 返回结果,不要满屏裸 then。裸 then 写起来爽,但塞进业务代码里五个连招下来,后面的人根本分不清哪个是错误处理、哪个是恢复逻辑。封装后会更清晰:
javascript复制async function getUserWithDefault() {
try {
return await fetchUser();
} catch {
return { name: '游客', id: -1 };
}
}
第四个建议:Promise 与 async/await 要一起学,不要只学一个。只看 .then 不够,因为你看到的现代项目代码至少有一半是 async/await;只看 async/await 也不够,因为遇到 Promise.all、自定义 Promise 封装时你依然要回到 .then 去理解。两者是同一套东西的两种表达,交替使用才是常态。
我个人在实际项目里最常见的组合是:接口层用 async/await 写顺序逻辑,并发场景用 Promise.all,全量结果用 Promise.allSettled,超时控制用 Promise.race,底层封装用 new Promise(...)。几套东西配合起来,绝大多数业务异步场景都能覆盖。如果你刚接触 Promise,可以先用最简单的链式改写一个回调地狱,再慢慢把所有场景都换成 Promise 风格,反复几次,你会明显感受到异步代码的可维护性上了一个台阶。
