
本文深入探讨了在javascript中使用`async/await`处理循环中的`fetch`请求时常见的陷阱。针对`foreach`无法正确等待异步操作的问题,我们提出并详细演示了如何结合`promise.all`与`Array.prototype.map`,以高效、并行且结构清晰的方式管理多个异步网络请求,从而避免`await`语法错误并优化代码执行。
理解异步循环的挑战
在javaScript中处理异步操作,特别是涉及到网络请求(如fetch API)时,async/await语法极大地简化了代码的编写和理解。然而,当我们需要在一个循环中执行一系列异步操作并等待它们全部完成时,常见的循环结构如forEach可能会导致意想不到的问题。
forEach方法是同步的。它会遍历数组中的每个元素,并为每个元素执行提供的回调函数,但它不会等待回调函数内部的异步操作完成。这意味着,即使你在forEach的回调函数中使用了async关键字和await表达式,forEach本身也不会暂停执行,而是会立即进入下一个迭代,导致外部代码在所有异步操作完成之前就继续执行。更严重的是,如果在非async函数或非async生成器、非模块的顶层作用域中直接使用await,javascript会抛出SyntaxError: await is only valid in async functions, async generators and modules.。
考虑以下示例代码片段,它试图在一个projetosList数组上循环,为每个项目异步获取调度信息:
// 假设 getData 函数已定义,它返回一个 Promise function getData(url) { return new Promise((resolve, reject) => { fetch(url) .then((resp) => resp.json()) .then((data) => resolve(data)) .catch((error) => reject(error)); }); } // 假设 projetosList 已经填充了项目数据,例如: // let projetosList = ["123#Project A", "456#Project B"]; async function listarSchedules() { let allUserData = []; projetosList.forEach(async (item) => { // 注意这里的 async 回调 let projetoId = item.split("#")[0]; let urlSchedule = `https://gitlab.com/api/v4/projects/${projetoId}/pipeline_schedules?private_token=glpat-uSjCXDMEZPh5x6fChMxs`; // 这里虽然使用了 await,但 forEach 不会等待 const data = await getData(urlSchedule); // getData 返回 Promise // 假设 data 结构为 { description: "...", owner: { username: "..." } } let str = `${projetoId}#${data.description}#${data.owner.username}`; allUserData.push(str); }); // 问题:imprimirSchedule 很可能在 allUserData 填充完成之前就被调用 imprimirSchedule(allUserData); // 假设 imprimirSchedule 只是打印数据 } // 调用并等待所有调度信息获取完成(但实际上 forEach 内部的异步操作可能还没完成) listarSchedules().then(() => console.log("DONE"));
尽管listarSchedules函数被声明为async,并且forEach的回调函数也被声明为async,但forEach本身并不会等待这些异步回调完成。这意味着imprimirSchedule(allUserData)可能会在一个空的或不完整的allUserData数组上执行。为了正确地等待所有异步请求完成,我们需要一种机制来收集所有Promise并等待它们全部解决。
立即学习“Java免费学习笔记(深入)”;
解决方案:结合Promise.all与Array.prototype.map
解决上述问题的最佳实践是利用Array.prototype.map来创建一个Promise数组,然后使用Promise.all来等待所有这些Promise的解决。这种方法不仅能够确保所有异步操作都已完成,而且还能实现请求的并行执行,从而提高效率。
Array.prototype.map方法会遍历数组的每个元素,并对每个元素执行一个回调函数,然后将回调函数的返回值组成一个新的数组。当这个回调函数是async函数时,它会返回一个Promise。因此,map操作的结果将是一个Promise数组。
Promise.all方法接收一个Promise的可迭代对象(例如一个Promise数组),并返回一个新的Promise。这个新的Promise会在所有输入的Promise都解决(resolve)之后解决,其解决值是一个包含所有输入Promise解决值的数组,顺序与输入Promise的顺序一致。如果任何一个输入的Promise被拒绝(reject),那么Promise.all返回的Promise也会立即被拒绝。
下面是使用Promise.all和map重构后的listarSchedules函数:
// 假设 getData 函数和 imprimirSchedule 函数已定义 // function getData(url) { /* ... */ } // function imprimirSchedule(data) { console.log(data); } async function listarSchedules() { // 使用 Promise.all 结合 map 来并行处理所有请求 const allUserData = await Promise.all( projetosList.map(async item => { // 解构项目ID和名称,提高可读性 const [projetoId, projetoNome] = item.split("#"); console.log(`PROJETO ID: ${projetoId}, PROJETO NOME: ${projetoNome}`); let urlSchedule = `https://gitlab.com/api/v4/projects/${projetoId}/pipeline_schedules?private_token=glpat-uSjCXDMEZPh5x6fChMxs`; console.log("urlSchedule:", urlSchedule); // 等待 getData(urlSchedule) Promise 解决,并解构所需数据 // 假设 getData(url) 返回的数据对象包含 description 和 owner 属性, // 且 owner 属性是一个对象,包含 username 属性。 const {description, owner:{username}} = await getData(urlSchedule); // 返回处理后的数据字符串,这将成为 Promise.all 结果数组中的一个元素 return `${projetoId}#${description}#${username}`; }) ); // 此时 allUserData 已经包含了所有请求的结果 imprimirSchedule(allUserData); } // 调用并等待所有调度信息获取完成 listarSchedules().then(() => console.log("DONE")).catch(error => console.error("Error fetching schedules:", error));
实现细节与代码解析
-
projetosList.map(async item => { … }):
- map方法遍历projetosList数组中的每个item。
- async item => { … } 为map的回调函数,它被声明为async,这意味着它内部可以使用await,并且每次执行都会返回一个Promise。
- 因此,projetosList.map(…)的执行结果是一个由Promise组成的数组,每个Promise代表一个对getData的异步调用。
-
const [projetoId, projetoNome] = item.split(“#”);:
- 使用数组解构赋值从item字符串中提取projetoId和projetoNome,代码更简洁易读。
-
const {description, owner:{username}} = await getData(urlSchedule);:
- await getData(urlSchedule)会暂停当前async回调的执行,直到getData返回的Promise解决。
- 一旦Promise解决,其结果(即API响应数据)会被赋值给{description, owner:{username}},利用对象解构赋值直接提取所需的属性。
-
return${projetoId}#${description}#${username};:
- 每个map回调函数返回一个格式化的字符串。这个字符串是该次getData调用成功后的最终结果。
- 当Promise.all解决时,它将返回一个数组,其中包含了所有这些返回的字符串。
-
await Promise.all(…):
- 最关键的一步。它等待map生成的所有Promise都成功解决。
- 一旦所有Promise都解决,Promise.all将解决为一个包含所有结果的数组,并将其赋值给allUserData。
- 此时,allUserData数组中已经包含了所有项目的调度信息,可以安全地传递给imprimirSchedule函数。
注意事项与最佳实践
-
错误处理: Promise.all的特性是“全有或全无”。如果它接收的任何一个Promise被拒绝,Promise.all本身会立即拒绝,并返回第一个被拒绝的Promise的错误。在实际应用中,你可能需要用try/catch块来捕获await Promise.all(…)可能抛出的错误,或者在map的回调函数内部处理单个请求的错误(例如,使用try/catch并返回一个默认值或错误标记)。
// 示例:在 Promise.all 外部捕获错误 try { const allUserData = await Promise.all(...); imprimirSchedule(allUserData); } catch (error) { console.error("处理调度信息时发生错误:", error); } // 示例:在 map 内部处理单个请求错误,让 Promise.all 始终成功 const allUserData = await Promise.all( projetosList.map(async item => { try { const [projetoId] = item.split("#"); const urlSchedule = `...`; // 构建你的 URL const {description, owner:{username}} = await getData(urlSchedule); return `${projetoId}#${description}#${username}`; } catch (error) { console.warn(`获取项目 ${item} 调度信息失败:`, error); return `${item}#ERROR#UNKNOWN`; // 返回一个错误标记或默认值 } }) ); -
并发限制: 对于非常大量的网络请求,Promise.all会同时发起所有请求。这可能会导致服务器过载、API限流或客户端资源耗尽。在这种情况下,你可能需要实现并发控制,例如使用第三方库(如p-limit)或手动创建队列来限制同时进行的请求数量。
-
Promise.allSettled: 如果你希望即使某些Promise失败,也能获取所有Promise的最终状态(无论是fulfilled还是rejected),那么Promise.allSettled是一个更好的选择。它返回一个Promise,该Promise在所有给定的Promise都已解决或拒绝后解决,其解决值是一个对象数组,每个对象描述了相应Promise的结果。
总结
在JavaScript中使用async/await处理循环中的异步fetch请求时,Promise.all与Array.prototype.map的组合是实现并行处理和正确等待所有异步操作完成的强大且优雅的模式。它不仅解决了forEach无法等待异步操作的问题,还提升了代码的可读性和执行


