async/await 让我们能用同步的方式书写异步代码,告别了恼人的“回调地狱”。然而,一个经典的场景常常让开发者感到困惑:
场景: 我需要循环请求一个用户列表,为什么用了 async/await 之后,页面会长时间白屏,直到所有请求都完成后才显示内容?async/await 不是非阻塞的吗?它怎么会阻塞页面渲染呢?
这一个问题触及了 async/await、事件循环(Event Loop)和浏览器渲染机制的核心。
误解澄清:await阻塞的是什么?
首先,我们必须明确一个核心概念:
async/await 本身绝不会阻塞 JavaScript 主线程,它是一种非阻塞的语法糖
当 JavaScript 引擎遇到 await 关键字时,它会暂停当前 async 函数的执行,将控制权交还给主线程。主线程此时是自由的,可以去处理其他任务,比如响应用户输入、执行其他脚本、以及最重要的——进行页面渲染。当 await 后面的 Promise 完成后,事件循环会再将 async 函数的后续代码推入任务队列,等待主线程空闲时恢复执行。
听起来很完美,那为什么我们的页面还是被“阻塞”了呢?
真正的元凶:串行执行的 await
让我们来看看那个导致“阻塞感”的罪魁祸首代码:
// 模拟一个 API 请求
function fetchUser(id) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Fetched user ${id}`);
resolve({ id: id, name: `User ${id}` });
}, 1000); // 每个请求耗时 1 秒
});
}
// 错误示范:在 for 循环中串行使用 await
async function fetchAllUsers(userIds) {
console.time('Fetch All Users');
const users = [];
for (const id of userIds) {
// 关键点:循环会在这里暂停,等待上一个请求完成后再开始下一个
const user = await fetchUser(id);
users.push(user);
}
console.timeEnd('Fetch All Users');
// 假设这里是更新 UI 的操作
renderUsers(users);
return users;
}
const userIds = [1, 2, 3, 4, 5];
fetchAllUsers(userIds);
// 控制台输出:Fetch All Users: 5005.12ms
问题显而易见: 这 5 个请求是串行的,一个接一个地执行。总耗时约等于所有请求耗时之和(5秒)。renderUsers(users) 这个最终更新 UI 的操作,必须等到这漫长的 5 秒全部结束后才能被调用。
在这 5 秒钟内,虽然主线程没有被 await 本身阻塞(它在 await 期间可以响应别的事件),但我们的业务逻辑人为地创造了一个漫长的等待。用户看到的就是一个长时间不更新的页面,这就是“阻塞感”的来源。
并发处理的正确姿势:Promise.all
那么,如何将这些串行的请求变成并行的呢?这些请求之间并没有依赖关系,完全可以同时发出!答案就是 Promise.all。
Promise.all 接收一个 Promise 数组作为参数,它会返回一个新的 Promise。这个新的 Promise 会在所有输入的 Promise 都成功(fulfilled)后才成功,并将所有结果汇总成一个数组返回。
让我们来改造一下上面的代码:
// 正确示范:使用 Promise.all 并发处理
async function fetchAllUsersConcurrently(userIds) {
console.time('Concurrent Fetch');
// 1. 将所有请求(Promise)放入一个数组,此时请求已经全部发出!
const promises = userIds.map(id => fetchUser(id));
// 2. 使用 Promise.all 等待所有请求完成
const users = await Promise.all(promises);
console.timeEnd('Concurrent Fetch');
renderUsers(users);
return users;
}
fetchAllUsersConcurrently(userIds);
// 控制台输出: Concurrent Fetch: 1008.25ms
总耗时从 5 秒骤降至 1 秒!这才是我们想要的效率,UI 也能更快地得到更新。
进阶:更多并发控制工具
Promise.all 非常强大,但它不是唯一的工具。在不同场景下,我们还有更合适的选择。
1. Promise.allSettled:不在乎失败,只在乎结果
Promise.all 有个“缺点”:只要有一个 Promise 失败(rejected),它就会立即失败,并且不会返回任何已成功的结果。如果我们希望无论成功与否,都等待所有请求完成,并获取它们各自的状态,Promise.allSettled 是我们的不二之选。
// fetchUser(3) 会失败
// const promises = [fetchUser(1), fetchUser(2), fetchUserThatFails(3)];
// const results = await Promise.allSettled(promises);
/* results 会是这样:
[
{ status: 'fulfilled', value: { id: 1, ... } },
{ status: 'fulfilled', value: { id: 2, ... } },
{ status: 'rejected', reason: 'Error: User not found' }
]
*/
2. Promise.race& Promise.any:谁快用谁
- Promise.race:赛跑。返回的 Promise 会以第一个 settle(无论是成功还是失败)的 Promise 的结果为准。适用于需要从多个源获取数据,但只用最快返回的那个的场景(比如CDN测速)。
- Promise.any:返回的 Promise 会以第一个成功(fulfilled)的 Promise 的结果为准。如果所有 Promise 都失败了,它才会失败。
3. 控制并发数量:避免瞬间打垮服务器
如果 userIds 的长度是 1000 呢?使用 Promise.all 会瞬间发出 1000 个请求,这可能会对我们的服务器造成巨大压力,甚至触发浏览器的并发请求数限制。
这时,我们需要一个“并发池”来控制同时进行的任务数量。我们可以手动实现一个简单的并发控制器:
async function limitedConcurrency(tasks, limit) {
const results = [];
const executing = []; // 正在执行的任务
for (const task of tasks) {
// 1. 创建并开始一个任务的 Promise
const p = Promise.resolve().then(() => task());
results.push(p); // 存储 Promise 的最终结果
// 2. 当任务执行完毕后,从 executing 数组中移除
if (limit <= tasks.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
// 3. 如果正在执行的任务达到上限,就等待其中一个完成
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.all(results);
}
// 使用方法
const userIds = [1, 2, 3, 4, 5, 6, 7];
// 将 fetchUser 调用包装成无参函数
const tasks = userIds.map(id => () => fetchUser(id));
// 同时只允许 3 个请求并发
limitedConcurrency(tasks, 3).then(users => {
console.log('All users fetched with limited concurrency:', users);
});
这个函数会确保同时在“飞行”的请求数量不会超过 limit。当然,在实际项目中,我们也可以使用成熟的第三方库来更优雅地解决这个问题。