好好的并行请求,被我写成了串行
前几天写个小 demo 页面,要同时拉一句诗词一言和模拟的天气数据,我顺手写了两个 await 串着跑,跑起来总觉得页面加载慢吞吞的。打开控制台掐了个时间,好家伙,两个接口加起来耗了快 2.5 秒,可明明单个请求最慢也就 2 秒。
javascript
function getWeather() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ temp: 38, conditions: 'Sunny with Clouds'})
}, 2000) // 模拟 2 秒返回
})
}
function getTweets() {
return new Promise((resolve) => {
setTimeout(() => {
resolve(['I like cake', 'BBQ is good too!'])
}, 500) // 模拟 500 毫秒返回
})
}
async function main() {
console.time("my-operation");
const weatherData = await getWeather();
const tweetsData = await getTweets();
console.log(weatherData);
console.log(tweetsData);
console.timeEnd("my-operation");
// 当时跑出来大概 2.5s 左右
}
现在回头看,这写法纯纯浪费时间。两个请求八竿子打不着,谁也不依赖谁的返回值,凭啥要等天气数据拿到了,才去发第二条请求?但写的时候就是顺手,await 一行行往下敲,完全没过脑子,估计很多人刚开始写异步代码的时候都干过这事。
改完 Promise.all,耗时直接砍了近一秒
反应过来之后我立刻改成了 Promise.all 的写法,把两个请求塞到一个数组里传进去,再统一 await。
javascript
const main = async () => {
console.time("my-operation");
// 两个请求一起发
const [weatherData, tweetsData] = await Promise.all([getWeather(), getTweets()]);
console.log(weatherData);
console.log(tweetsData);
console.timeEnd("my-operation");
// 跑出来大概 2s 左右,刚好等于最慢的那个请求
}
跑完一看耗时,2 秒出头,直接省了 500 毫秒。
说实话这个道理我之前也听过,"Promise.all 可以并行执行异步任务,总耗时等于最慢的那一个"。但真到自己写代码的时候,脑子一热就写成串行的了。
你可以把 Promise.all 理解成一个小组作业的组长:你把好几件任务(Promise)扔给它,它就让所有人同时开工。等所有人都交作业了,它就把结果打包好一起给你;但凡有一个人摆烂说做不完,它直接就宣告整个小组任务失败,剩下的人做没做完它都不等了。

每个 Promise 生下来都是 pending(待处理)状态,要么成功变成 fulfilled,要么失败变成 rejected,一旦状态定死了就再也改不了。Promise.all 干的事,就是盯着这一堆 Promise 的状态,等它们全部变成 fulfilled,或者有任何一个变成 rejected,它就给出最终结果。
我踩的第一个坑:结果顺序居然不是谁快谁在前
改完代码我盯着输出看了半天,突然犯了个嘀咕:getTweets 500 毫秒就返回了,getWeather 要 2 秒,按道理快的应该先到吧?怎么结果数组里第一个永远是 weather,第二个才是 tweets?
我当时还以为自己写反了,来回调换了三次数组里的顺序,最后才确认:结果的顺序,和 Promise 完成的先后半毛钱关系都没有,只和你传进数组的顺序一一对应。
javascript
// 哪怕 tweets 先跑完,结果数组里第 0 位永远是 weather 的结果
const [weatherData, tweetsData] = await Promise.all([getWeather(), getTweets()]);
// 别问我为什么知道,试了三次才敢确定
后来想通了也合理,要是顺序按完成时间来排,那代码根本没法写 ------ 你永远不知道拿到的数组里第几个对应哪个请求,总不能每次都去判断字段吧。这个设计看起来 "反直觉",实际上是帮我们省了好多麻烦。
一个容易忽略的细节哪怕某个任务 10 毫秒就返回了,另一个要 10 秒,最终结果数组里的顺序,还是和你传进
Promise.all的顺序完全一致。写解构赋值的时候放心按传入顺序写就行。
第二个坑:一个请求炸了,所有结果都拿不到
顺顺利利用了两天,我又踩了第二个坑。
那天我在调一言的接口,写了两个并行请求,手滑把其中一个 URL 写错了。本来我以为,错的那个报错就报错呗,另一个正常的总该能拿到数据吧?结果控制台直接红了,整个 Promise.all 直接走进了 catch,成功的那个请求的数据连影子都没见着。
javascript
const getStory = async () => fetch('https://v1.hitokoto.cn/?c=i&encode=json');
const getError = async () => fetch('https://不存在的地址.cn/'); // 故意写错的
Promise.all([getStory(), getError()])
.then(res => {
console.log('成功了', res);
})
.catch(err => {
console.log('失败了', err);
// 只要有一个失败,直接走到这里,成功的结果也拿不到
});
当时我懵了,不至于这么极端吧?一个失败而已,怎么连成功的都不给我了?
翻了下规范才确认,这就是
Promise.all的核心规则:全成才成,一败就败 。只要数组里有任何一个 Promise 变成 rejected 状态,整个Promise.all就会立刻 rejected,直接把第一个失败的原因抛给 catch,剩下还在执行的 Promise 它也不会等了。
我笔记里记的那句 "只要有一个失败,整体失败,不再等待其他 promise 执行,走 catch 第一个失败的原因",就是踩完这个坑之后补上的。光看文档没感觉,真踩一次就记牢了。
再往深扒一层:Promise 什么时候开始执行?
踩完两个坑之后我就开始好奇一件事:这些 Promise,到底是调用 Promise.all 的时候才开始执行,还是更早?
之前我一直想当然地觉得,肯定是 Promise.all 拿到数组之后,才挨个启动里面的任务。直到有次我随手把 Promise 提前创建了,过了一秒才塞进 Promise.all,结果总耗时居然没变。
javascript
// 函数一调用,Promise 就已经开始执行了
const p1 = getWeather();
const p2 = getTweets();
// 这里同步代码执行了 1 秒(只是举例子,别真写死循环)
// ...
// 1 秒后才调用 Promise.all
const res = await Promise.all([p1, p2]);
// 总耗时还是 2 秒左右,因为任务早就开始跑了
后来才搞明白:Promise 在创建的那一刻就已经开始执行了 。new Promise 里面的代码是同步执行的,异步操作(比如 setTimeout、请求)也会立刻发起。Promise.all 啥都没干,它只是个 "等结果的人",不是 "发任务的人"。
这个点很多人都容易搞错,以为把 Promise 放数组里存着就不会执行,等 all 的时候才开始。其实不是,你只要调用了返回 Promise 的函数,任务就已经发出去了。
批量请求的常见写法:别忘了二次 Promise.all
平时开发里最常用的场景,其实是批量发请求,然后批量解析返回结果。比如用 fetch 发请求,拿到的 Response 对象不是直接的 JSON 数据,得调用 .json() 方法,而这个方法本身也返回一个 Promise。
我第一次写批量 fetch 的时候,就踩了这个坑:
javascript
// 错误写法
const resList = await Promise.all([getStory(), getRatp()]);
const dataList = resList.map(item => item.json());
console.log(dataList);
// 打印出来全是 Promise 对象,不是数据
// 我当时盯着屏幕看了三分钟,心想我不是 await 过了吗?
拍了下脑袋才反应过来:.json() 本身就是异步的啊!map 遍历完得到的还是一堆 Promise,当然得再用一次 Promise.all 等它们全部解析完。
正确的写法应该是这样:
javascript
const getStory = async () => fetch('https://v1.hitokoto.cn/?c=i&encode=json');
const getRatp = async () => fetch('https://v1.hitokoto.cn/?c=i&encode=json');
Promise.all([getStory(), getRatp()])
.then(res => {
// 把所有 response 转成 json,再用 Promise.all 等全部解析完
return Promise.all(res.map(item => item.json()));
})
.then(dataList => {
console.log(dataList);
// 这里拿到的才是真正的 JSON 数据数组
})
.catch(err => {
console.log(err);
});
如果你喜欢用 async/await,也可以写成两行 await:
javascript
运行
javascript
async function main() {
const resList = await Promise.all([getStory(), getRatp()]);
const dataList = await Promise.all(resList.map(item => item.json()));
console.log(dataList);
}
两种写法本质一样。核心就是记住:只要里面还有 Promise,就别想当然地以为拿到了最终值,该 all 还得 all。
最后聊聊,什么时候该用,什么时候别乱用
其实 Promise.all 本身不难,语法也简单,难的是别用错场景。
适合用的场景其实很明确:好几个异步任务互相没有依赖关系,你必须等所有任务都完成了才能往下走。比如页面初始化的时候,同时拉取用户信息、列表数据、配置项,这几个接口谁也不依赖谁,用 Promise.all 并行发,能明显减少页面加载时间。还有批量上传文件、批量查询数据之类的,都很合适。
但有几种情况就别硬用了:
- 任务之间有依赖,第二个请求需要第一个的返回值,老老实实串行写 await,别为了炫技搞并行。
- 你不关心失败,只想拿到所有成功的结果,失败的忽略掉。这种情况用
Promise.allSettled更合适,别硬用Promise.all然后自己写一堆 catch 容错。 - 你只需要最快的那个结果,剩下的无所谓。那直接用
Promise.race,谁先回来用谁。
说实话,这些东西看文档的时候都觉得 "这不废话吗",真自己写代码,该踩的坑一个都少不了。很多细节都是摔过一次之后,才真正刻进脑子里。
你们平时写 Promise 的时候,踩过什么印象特别深的坑?评论区说出来,大家一起避避坑。