为何 async/await 会“阻塞”页面?并发处理的正确姿势


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)后才成功,并将所有结果汇总成一个数组返回。

让我们来改造一下上面的代码:

总耗时从 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 。当然,在实际项目中,我们也可以使用成熟的第三方库来更优雅地解决这个问题。