面试的时候我经常问候选人一个问题:setTimeout 和 Promise.then 谁先执行?能完整答上来的人,比想象中少。很多人背过“Promise 是解决回调地狱的”,但一问到 catch 到底能不能捕获 then 里抛出的错误,又开始含糊。
其实 Promise 是 ES6 里最值得花时间吃透的知识点之一,它不仅改变了我们写异步代码的方式,也几乎是现代前端面试的必考项。这篇内容我从最基础的核心机制讲起,一直延伸到真实业务场景里的封装、超时、并发控制,以及排查 Promise 相关报错的思路。不管你是刚接触 JavaScript 的初学者,还是已经写过一段时间却总觉得 Promise 有些地方说不清的同学,这篇文章都适合你。
1. Promise 到底是什么——先把它放在真实场景里
1.1 没有 Promise 之前,回调函数有多痛苦
要理解 Promise 的价值,得先回到 ES5 时代。那时候做一次网络请求,用的是 XMLHttpRequest,写出来的代码大概长这样:
javascript复制function getData(url, onSuccess, onError) {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
onSuccess(xhr.responseText);
} else {
onError(new Error('请求失败'));
}
};
xhr.onerror = function () {
onError(new Error('网络错误'));
};
xhr.send();
}
单看这段倒还好,问题在于真实业务里请求往往是有依赖关系的。比如先拿用户信息,再拿用户的订单列表,再拿订单里的商品详情。用回调函数写就是传说中的“回调地狱”:
javascript复制getData('/api/user', function (user) {
getData('/api/orders?userId=' + user.id, function (orders) {
getData('/api/goods?orderId=' + orders[0].id, function (goods) {
// 到这里,缩进已经没法看了
render(goods);
}, function (err) {
console.error('商品详情失败', err);
});
}, function (err) {
console.error('订单列表失败', err);
});
}, function (err) {
console.error('用户信息失败', err);
});
这个代码有几个很要命的问题:每次出错都要单独处理,错误分支层层嵌套;多个操作之间靠缩进强行组织,可读性极差;代码的“形状”和逻辑的“顺序”完全对不上。
Promise 所解决的正是这些问题。它把异步操作的最终结果抽象成了一个对象,这个对象在未来的某个时刻会变成“成功”或者“失败”,你可以像处理一个普通值一样去组合、传递、链式调用它。
1.2 Promise 的核心:状态机与不可逆性
很多资料讲 Promise 会直接给定义的代码,但我觉得真正关键的是它底层那套状态机制。一个 Promise 对象只有三种状态:
pending:进行中,还没有得到结果;fulfilled:已成功,已经拿到结果值;rejected:已失败,已经拿到失败原因。
这个状态机有两条铁律:
第一,状态只能从 pending 变为 fulfilled 或 rejected,一旦变化就不可逆。也就是说一个 Promise 不可能从成功退回到进行中,也不可能从失败变成成功。这其实模拟了现实世界——一次异步操作的结果是确定的,不能事后篡改。
第二,then 和 catch 里注册的回调不会立即执行,而是等当前脚本执行完之后,在微任务队列里按顺序执行。这涉及事件循环机制,后面我会单独展开。
理解了这两条,你就能明白为什么 Promise 也被称为“唯一可信的异步结果”:因为它的状态变化是受控的,不会被外部干扰,也不会发生“回调被调用了两次”这种经典 bug。
javascript复制const promise = new Promise((resolve, reject) => {
resolve('成功');
reject('失败'); // 这一行不会生效,状态已经变成 fulfilled 了
resolve('再次成功'); // 也不会生效
});
promise.then(value => {
console.log(value); // 输出:成功
});
我见过不少初学者在这里犯迷糊,觉得自己调用了两次 resolve,回调就会执行两次。实际上 Promise 内部对状态变化做了保护,第二次调用直接忽略。这个设计能防止网络请求这类场景中,回调被重复触发导致的数据错乱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Promise 基础 API,把这些方法用熟
2.1 手动创建 Promise 与 then、catch、finally
先说怎么手动创建一个 Promise。构造器接收一个回调函数,这个回调函数被称为“执行器”(executor),它会被立即执行,并且接收两个参数:resolve 和 reject。
javascript复制function requestData(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (url) {
resolve({ code: 0, data: '模拟数据' });
} else {
reject(new Error('url 不能为空'));
}
}, 1000);
});
}
注意一个细节:执行器是创建 Promise 时同步执行的,但 resolve 之后传递的结果,只能在 then 的回调里被拿到,因为回调是在微任务里触发的。如果你在 new Promise 之后紧接着写 console.log('同步代码'),它一定会先于 .then 里的回调输出。
then 接收两个参数,第一个是成功回调,第二个是失败回调。日常开发里更推荐的做法是只用 then 传第一个参数,搭配 catch 去统一处理失败,这样逻辑更清晰:
javascript复制requestData('/api/user')
.then(data => {
console.log('用户数据:', data);
})
.catch(err => {
console.error('请求出错:', err.message);
});
这里有一个新手最容易踩的坑:catch 只能捕获前面链条中抛出的错误,但如果你在 catch 之后的 then 里又出错了,需要再跟一个 catch。链条稍长就容易漏,后面我会讲更好的兜底方案。
finally 是 ES2018 引入的,无论成功失败都会执行,适合放 loading 关闭、状态重置这类清理逻辑。它有个特性:finally 不接收参数,也不知道 Promise 是成功还是失败。它最主要的价值是保证“不管结果如何,清理代码一定执行”。
2.2 静态方法:resolve、reject、all、race、allSettled、any
除了实例方法,Promise 还提供了一批静态方法,它们在我们的业务代码里出现的频率非常高,我按行为和适用场景整理成了一张表:
| 方法名 | 行为 | 适用场景 | 注意点 |
|---|---|---|---|
Promise.resolve(value) |
返回一个立即成功的 Promise | 把普通值包装成 Promise,统一异步处理 | 如果传入的是一个 Promise,会原样返回 |
Promise.reject(reason) |
返回一个立即失败的 Promise | 装饰器/拦截器中的快速失败 | 需要用 catch 处理,否则会报 unhandledrejection |
Promise.all(iterable) |
全部成功才成功,任何一个失败整体失败 | 多个接口并行请求,且全部成功才算通过 | 失败时只会拿到第一个 reject 的原因,其余结果丢失 |
Promise.race(iterable) |
第一个 settle(成功或失败)的结果为最终结果 | 超时控制、轮询 | 竞速失败的 Promise 仍可能触发未处理异常,注意兜底 |
Promise.allSettled(iterable) |
等所有 Promise 都 settle,返回每个结果的状态和值 | 多个请求互相独立,不想因为一个失败而中断 | 结果永远成功,不会进入 catch |
Promise.any(iterable) |
第一个成功的为结果,全部失败才失败 | 多个备选资源,取最快成功的那个 | 全部失败时返回 AggregateError |
Promise.all 是最常用的,但它有个明显的缺陷:一旦一个请求失败,整个 Promise 就 reject 了,其它请求的结果也拿不到。这在“全部成功才算成功”的场景下是对的,但如果是多个独立接口,比如页面上同时加载用户信息、系统配置、积分余额,我希望某个接口失败时不渲染对应模块,而不是整个页面白屏。这时候 Promise.allSettled 更合适。
javascript复制const promises = [
fetchUserInfo(),
fetchSystemConfig(),
fetchBalance()
];
Promise.allSettled(promises).then(results => {
const renderTasks = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderTasks.push(renderByIndex(index, result.value));
} else {
console.warn(`第 ${index} 个请求失败:`, result.reason);
}
});
renderAll(renderTasks);
});
Promise.race 我前两年用得最多的地方是“请求超时控制”,具体实现放到下一章讲。
3. 真实业务场景:把 Promise 用起来
3.1 封装一个可用的 fetch 请求函数
Promise 在真实项目里很少被直接 new,更多是封装在请求库里。比如用 fetch 实现一个带超时和统一错误处理的请求函数:
javascript复制function request(url, options = {}) {
const controller = new AbortController();
const { timeout = 10000, ...restOptions } = options;
const timeoutPromise = new Promise((resolve, reject) => {
setTimeout(() => {
controller.abort();
reject(new Error(`请求超时:${url}`));
}, timeout);
});
const fetchPromise = fetch(url, {
...restOptions,
signal: controller.signal
}).then(response => {
if (!response.ok) {
throw new Error(`HTTP 错误:${response.status}`);
}
return response.json();
});
return Promise.race([fetchPromise, timeoutPromise]);
}
这段代码里 Promise.race 把“真正的请求”和“定时器”放在一起竞速。如果请求先到,定时器被忽略;如果定时器先触发,就 controller.abort() 取消请求,并抛出超时错误。
这里有个细节经验:Promise.race 竞速失败的 Promise 不会被销毁,它后续还是可能触发一些副作用。所以在 fetch 里用 AbortController 主动取消,不仅是为了快速响应用户,也是避免底层网络请求继续占用资源。
3.2 并发控制:限制同时发起的请求数量
浏览器对同一域名的并发连接数有限制,通常 HTTP/1.1 下一次性发起几十个请求会有问题。我写过一个小工具,用 Promise 实现并发数量限制,每次最多同时跑 3 个请求:
javascript复制async function asyncPool(limit, tasks, handler) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const promise = Promise.resolve().then(() => handler(task, results.length));
results.push(promise);
executing.add(promise);
const clean = () => executing.delete(promise);
promise.then(clean, clean);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
asyncPool(3, taskList, async task => {
const data = await request(`/api/data/${task.id}`);
return process(data);
});
核心思路是:维护一个正在执行的 Promise 集合,每次调用 Promise.race(executing) 等待任一请求完成,空出名额后再继续添加新任务。这个模式相当于把并发控制抽离出来,比手写一堆 for 循环和标记位要清晰得多。
如果需要按顺序返回结果,可以直接 await Promise.all(results);如果只是需要知道全部完成,也可以用 allSettled 避免一个任务失败影响整体。
3.3 接口失败自动重试与退避
网络请求偶尔失败是很正常的,直接在用户脸上抛个错误体验不好。一个带退避的重试函数可以这样写:
javascript复制function retry(fn, retries = 3, delay = 500) {
return fn().catch(err => {
if (retries <= 0) {
throw err;
}
console.warn(`请求失败,剩余重试次数:${retries},错误原因:${err.message}`);
return new Promise(resolve => setTimeout(resolve, delay)).then(() =>
retry(fn, retries - 1, delay * 2)
);
});
}
retry(() => request('/api/important'), 3, 500)
.then(data => render(data))
.catch(err => {
showToast('加载失败,请稍后重试');
});
再强调一下:不是所有接口都适合无脑重试。幂等性不确定的 POST 请求,比如订单支付、表单提交,重试可能造成重复操作,需要先保证请求幂等,或者用类似“请求指纹”的机制做去重。
4. 进阶:事件循环、async/await 与异常兜底
4.1 微任务到底在什么时候执行
我开头提的那个面试题:“setTimeout 和 Promise.then 谁先执行”,很多人答不上来,其实是没搞清楚事件循环里的微任务机制。
简单说,JavaScript 的主线程把代码从上到下执行完后,会清空微任务队列(Microtask Queue),然后才去取宏任务队列(Macrotask Queue)里的下一个任务。Promise.then、catch、finally、queueMicrotask 的回调都进微任务队列;setTimeout、setInterval、I/O、UI render 这些进宏任务队列。
javascript复制console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});
console.log('script end');
这段代码的输出顺序是:script start → script end → promise1 → promise2 → setTimeout。原因是微任务队列会在当前宏任务结束时被完全清空,而 setTimeout 的回调属于下一个宏任务。
如果你写的代码里同时有大量微任务,就会阻塞渲染。比如在一个很长的 forEach 循环里同步 resolve 很多 Promise,页面可能会卡顿,因为渲染更新也是宏任务,得等微任务全跑完才有机会执行。
4.2 async/await:用同步的姿势写异步
async/await 本质上就是 Promise 的语法糖,它让异步代码的写法更像同步代码,从而减少链式回调的割裂感。
javascript复制async function loadPageData() {
try {
const user = await request('/api/user');
const orders = await request(`/api/orders?userId=${user.id}`);
render(orders);
} catch (err) {
showError(err.message);
}
}
这里有几个容易忽略的点:
第一,async 函数无论内部返回什么,最终都会包成一个 Promise。哪怕你在里面直接 return 'abc',调用方接到的也是一个 Promise 对象,需要 await 或 .then 才能拿到 'abc'。
第二,await 后面等的是一个 Promise,但也可以是一个普通值。如果等的是普通值,则直接返回该值。
第三,async/await 里的错误处理。try/catch 可以捕获 await 表达式的错误,但这种捕获只能在同一个 try 块内生效。如果整个 async 函数没有 try/catch 包裹,函数返回的 Promise 就会进入 rejected 状态,必须在调用端处理。
一个很常见的隐患是:
javascript复制async function init() {
const data = await request('/api/data');
render(data);
}
init(); // 如果这里 reject 了,而没有任何处理,就会出 unhandledrejection
所以要么在 init() 调用处加 .catch(),要么在 async 函数内部用 try/catch 兜底。否则控制台会出现神秘的 Uncaught (in promise) ...,这种错误排查起来特别难受,因为报错堆栈往往不指向真正的业务代码。
4.3 unhandledrejection:最后的全局兜底
不管怎么小心,总有漏网之鱼。我在项目里习惯加一个全局的 unhandledrejection 监听,专门处理那些没有被捕获的 Promise 失败:
javascript复制window.addEventListener('unhandledrejection', event => {
event.preventDefault();
const reason = event.reason;
console.error('未处理的 Promise 失败:', reason);
// 上报监控平台
if (window.reportErrorToServer) {
window.reportErrorToServer({
type: 'unhandledrejection',
message: reason?.message || String(reason),
stack: reason?.stack
});
}
});
Node.js 环境里是 process.on('unhandledRejection'),写法类似。注意,这个兜底只能防止页面报错,永远替代不了代码里的正确容错。你不可能靠一个全局监听去“修复”所有错误,它只是让你不至于错过错误,同时在出问题时能第一时间拿到堆栈和现场信息。
4.4 几类真实报错的定位思路
结合我平时收到的错误日志,有几种报错特别常见,也特别容易让新手一头雾水。我直接列出来说明定位思路。
Uncaught (in promise) NotAllowedError: play() failed because the user didn't interact with the document first. 是浏览器自动播放策略导致的。视频、音频要在用户主动触发的事件里调用 play(),不能一进页面就自动播放。解决办法是在按钮点击事件里先调用一次 resume() 或拿到用户手势后再播放。
Could not establish connection. Receiving end does not exist. 常见于扩展程序或 iframe 间的消息通信。本质是发消息时接收方的上下文已经被销毁或不存在了,常见于用户关闭了某个页面、刷新后旧页面还在发消息。需要在发送前检查 port 或 targetWindow 是否存在,并且监听对方 disconnect 事件。
A JavaScript error occurred in the main process 在 Electron 应用里会以弹窗形式出现。如果你在主进程里 new Promise 但回调里抛错了,或者 IPC 通信时 receive 事件绑定的回调出问题,就会被这个小弹窗捕获。可以看主进程的完整堆栈日志,或打开主进程的 Promise 拒绝兜底处理。
5. 常见问题与排查技巧速查
5.1 一份可以直接对着排查的速查表
| 问题现象 | 常见原因 | 排查思路 |
|---|---|---|
Uncaught (in promise) 报错,看不到具体堆栈 |
某个 Promise 没有 catch,但错误在异步链路深处 | 全局监听 unhandledrejection,打印 event.reason |
then 里 return 一个 Promise,外层拿到的不是值而是 Promise |
不了解 then 返回值的回调规则 |
在 then 里做 return 时需要嵌套接一层,或者在 async 函数里直接 await |
Promise.all 后接口报错但无法知道是哪个接口出的错 |
Promise.all 只会返回第一个 reject 的原因 |
用 allSettled 替代;或者在每个接口单独 .catch 返回可识别的错误对象 |
finally 里想把值传给下一个 then 但拿不到 |
finally 不接收参数,也不处理值 | finally 只做清理,别传值 |
await 后页面卡顿 |
微任务过多阻塞渲染 | 检查是否有大量 Promise.resolve 未分批处理 |
setTimeout 回调里使用 resolve,但 then 一直没执行 |
可能是作用域链问题,resolve 没有被正确捕获 | 打日志检查执行顺序,确认回调是否真正被调用 |
刷新页面后报 Receiving end does not exist |
消息接收窗口已销毁 | 发送消息前检查目标窗口/端口状态,订阅 disconnect 事件 |
5.2 一些实际踩过坑之后总结的经验
第一,每个独立的异步请求都要有兜底。所谓独立,就是它不是某个链路的中间环节,而是链路的数据源。如果一个请求失败会导致页面整块不能用,你有两个选择:要么在调用处显示错误态,要么用 Promise.allSettled 让其它模块继续渲染。
第二,then 里 return Promise 会产生“等待”效果。很多人觉得链式写法不好读,但如果你掌握了这个规则,就能写出很短的串行逻辑:
javascript复制request('/api/login')
.then(data => request(`/api/profile?id=${data.userId}`))
.then(profile => renderProfile(profile))
.catch(err => showLoginError(err));
这个链条相当于用 await 串行执行了登录请求和资料请求。关键区别在于,用 catch 兜底比在 async 里手动 try/catch 更不容易漏。
第三,调试 Promise 时我强烈建议在关键节点打日志而不是依赖报错堆栈。因为 Promise 的异步链路会让堆栈变得很长,而且微任务的报错堆栈往往被截断了。
第四,如果代码里出现“无限递归更新”相关的报错(Vue/React 项目常见,比如 Maximum recursive updates exceeded),多半不是 Promise 的问题,而是某个 then 回调里再次触发了数据更新,从而再次触发了同一个 then。这种情况需要检查状态更新的条件判断,想办法让更新只在数据真正变化时触发。
6. 经典面试题与自检清单
6.1 考试重点:先自己想一遍再看答案
我问一个人 Promise 掌握得怎么样,一般看三个问题:
问题一:Promise.all 和 Promise.allSettled 的区别,什么场景下你会用后者?思路:前者有“短板效应”,任何一个失败则整体失败;后者等所有都出结果,适合多个独立请求并行加载。
问题二:Promise.race 实现请求超时,怎么避免竞速失败的 Promise 触发未捕获异常?思路:给竞速失败的 Promise 补一个 .catch(() => {}),或者用 AbortController 从根本取消请求。
问题三:async await 里的 try/catch 只能捕获到哪些错误?思路:只能捕获同一个 try 块内部 await 的 Promise 的 rejected;如果 async 函数外部调用不处理,还是会产生未处理异常。
这三个问题能答好,说明你不是背 API,而是真理解运作机制。
6.2 自检清单:照着打勾
- [ ] 我知道 Promise 有几种状态,状态是否可逆
- [ ] 我能说出
then返回的新 Promise 的 resolve 规则 - [ ] 我能区分微任务和宏任务,并判断执行顺序
- [ ] 我知道
Promise.all、allSettled、race、any的区别 - [ ] 我能写出一个支持超时和错误处理的 fetch 封装
- [ ] 我知道
unhandledrejection的配置方法 - [ ] 我能解释
async/await的语法糖本质,以及它的错误捕获边界 - [ ] 我能定位常见的
Uncaught (in promise)报错
说实话,Promise 这个知识点虽然基础,但在实际开发里很容易被低估。它不像有些框架 API 一学就会,也不像算法题那样烧脑,但它是理解前端异步世界的基石。async/await 用起来爽是爽,可如果底层没有 Promise 做支撑,那些错误处理、并发控制、超时取消的场景会写得非常痛苦。
我在项目里见过太多把 Promise.all 当成万能药、把错误处理全扔到全局兜底的代码。其实 Promise 的美妙之处在于,它把复杂的异步流程拆成了一个一个小块,每个小块都可以单独测试、单独组合。你真正理解它的状态机和微任务时序之后,再回头看那些“诡异”的异步 bug,会发现大部分都有迹可循。
最后再分享一个小技巧:如果你在排一个跟时间序有关的异步 bug,不要猜,直接在关键位置用 console.log 打点,把执行顺序打出来。Promise 的时序问题靠肉眼是看不出来的,但日志一打,谁先谁后清清楚楚。写代码的时间长了你会发现,排查异步问题其实就是排查顺序问题,而 Promise 是帮我们控制顺序最好的工具之一。
