前言:一个常见的加载场景
在日常的前端开发中,我们经常会遇到这样的场景:页面初始化或下拉刷新时,需要同时从后端获取多个独立的业务数据,比如:首页的 Banner 图、分类列表、热门推荐等。
最近,我在优化一个下拉刷新功能时,就遇到了一段这样的代码:
javascript
// 当前下拉刷新状态
const isTriggered = ref(false)
// 自定义下拉刷新被触发
const onRefresherRefresh = async () => {
// 开启下拉刷新加载动画
isTriggered.value = true
// 加载数据
await getHomeBannerData() // 获取 轮播图 数据加载完成
await getHomeCategoryData() // 获取 前台分类 数据加载完成
await getHomeHotData() // 获取 热门推荐 数据加载完成
// 关闭下拉刷新加载动画
isTriggered.value = false
}
这段代码功能上是完全没问题的,但仔细一想,它的性能真的好吗?
问题分析:串行执行的"等待陷阱"
让我们来分析一下这段代码的执行流程。JavaScript 是单线程的,async/await 本质上是 Promise 的语法糖,遇到 await 关键字时,JS 引擎会暂停当前函数的执行,等待 后面的 Promise 对象 resolve 后,才会继续往下执行。
所以,上面代码的执行时间线是这样的:
开始\] -\> \[请求 Banner...耗时 500ms\] -\> \[请求 Category...耗时 300ms\] -\> \[请求 Hot...耗时 200ms\] -\> \[结束
总耗时 = 500ms + 300ms + 200ms = 1000ms
这就好比你去食堂打饭,有三个窗口:水果、荤菜、素菜。你排了一个窗口,打完一份后,又去排下一个窗口,打完再排最后一个。虽然最后三份菜都到手了,但你花费的时间是三次排队时间的总和。
这就叫串行执行。对于相互依赖的请求(比如:必须先获取用户 ID,才能根据 ID 获取用户详情),串行是必要的。但在我们这个场景中,获取 Banner、Category 和 Hot 数据是完全独立的,没有任何依赖关系。串行执行就会白白浪费大量的等待时间。



优化方案:并行执行的"多车道"思维
既然这三个请求是独立的,我们完全可以让它们同时发起,就像有多个朋友帮你去三个窗口同时打饭一样,最后谁先回来都行,总耗时取决于最慢的那个人。
在 JavaScript 中,实现并行异步操作的利器就是 Promise.all()。
Promise.all() 接收一个 Promise 对象组成的数组作为参数,它会返回一个新的 Promise。只有当数组中的所有 Promise 对象都变为 fulfilled(成功)状态时,它返回的 Promise 才会变为 fulfilled,并且会将所有 Promise 的结果组成一个数组返回。
来看看优化后的代码:
javascript
// 当前下拉刷新状态
const isTriggered = ref(false)
// 自定义下拉刷新被触发
const onRefresherRefresh = async () => {
// 开启下拉刷新加载动画
isTriggered.value = true
// 并行加载数据
// Promise.all 会同时发起三个请求,并等待它们全部完成
await Promise.all([
getHomeBannerData(),
getHomeCategoryData(),
getHomeHotData()
])
// 关闭下拉刷新加载动画
isTriggered.value = false
}


现在,执行时间线是这样的:
开始\] -\> \[请求 Banner, 请求 Category, 请求 Hot 同时进行\] -\> \[全部完成\] -\> \[结束
总耗时 = max(500ms, 300ms, 200ms) = 500ms
对比一下,优化后的加载速度直接提升了一倍!用户感知到的页面加载速度也会有显著的提升。
核心原理深度剖析:Promise.all 的特性
要真正掌握这个优化,我们需要深入理解 Promise.all 的几个关键特性:
- 并行发起: 当你把三个 Promise 传给
Promise.all时,这三个 Promise 对应的异步操作(比如网络请求)会被几乎同时发起。这一点非常重要,它不是等一个完了再发起下一个,而是"三箭齐发"。 - 结果数组的顺序:
Promise.all返回的结果数组顺序与你传入的 Promise 数组顺序是一一对应 的。比如,你传入的是[getBanner, getCategory],那么返回结果的第一个元素就是getBanner的结果,第二个就是getCategory的结果。这为我们处理结果提供了极大的便利,不用担心顺序混乱。 - "短板效应":
Promise.all的总耗时由最慢 的那个 Promise 决定。如果getBanner耗时 1 秒,其他两个只耗时 10ms,那Promise.all依然要等 1 秒才 resolve。这在逻辑上是合理的,因为我们的目标是"全部数据都加载完成"。 - "一票否决"机制: 如果 Promise 数组中有任何一个 Promise 变为
rejected(失败)状态,那么Promise.all就会立即变为rejected状态。这一点需要注意,如果某个接口报错,可能会导致其他成功加载的数据也无法被正常处理。
进阶思考:更稳健的 Promise.allSettled
在生产环境中,Promise.all 的"一票否决"机制有时会带来困扰。比如上面的下拉刷新场景,我们可能希望即使某个接口报错了(比如 Banner 接口挂了),也不影响其他数据(Category、Hot)的正常显示。
这时候,ES2020 引入的 Promise.allSettled 就派上用场了。
Promise.allSettled 会等待所有 Promise 完成,无论成功还是失败 。它最终会返回一个包含每个 Promise 状态(fulfilled 或 rejected)和结果的数组。
使用 Promise.allSettled 优化后的代码可以更加健壮:
javascript
const onRefresherRefresh = async () => {
isTriggered.value = true
const results = await Promise.allSettled([
getHomeBannerData(),
getHomeCategoryData(),
getHomeHotData()
])
// results 是一个数组,包含了每个 Promise 的最终状态和结果
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`请求 ${index} 成功:`, result.value)
// 这里可以更新对应的响应式数据,比如 banners.value = result.value
} else {
console.error(`请求 ${index} 失败:`, result.reason)
// 这里可以进行错误提示,或者显示兜底数据
}
})
isTriggered.value = false
}
这样,即使某个接口出现问题,用户依然可以看到其他成功加载的内容,体验会好很多。
总结与最佳实践
从一个简单的下拉刷新优化,我们引出了前端性能优化的一个重要原则:利用并行,减少等待。
- 识别独立请求: 当你遇到多个连续的
await调用时,首先问自己:这些请求之间有依赖关系吗?如果没有,就考虑用Promise.all将它们并行化。 - 选择合适的工具:
- 如果希望所有请求都必须成功,用
Promise.all。 - 如果希望容错,允许部分请求失败,用
Promise.allSettled。
- 如果希望所有请求都必须成功,用
- 注意并发限制: 虽然
Promise.all可以并行请求,但浏览器对同一域名下的并发 HTTP 请求数量是有限制的(通常是 6 个)。如果一次性并发几十个请求,反而会阻塞后续请求。在大多数常规业务场景中,3-5 个并发请求是完全没有问题的。
最后,记住一句话:性能优化往往隐藏在细节之中。一个简单的 Promise.all,就能让你的应用如虎添翼。