✍️ 面试高频:手把手教你实现Promise.all
🤔Promise.all的核心行为是什么?
在写代码之前,我们必须明确Promise.all的契约(Contract):
- 输入:接收一个可迭代对象(通常是数组),成员可以是 Promise 实例,也可以是普通值。
- 输出:返回一个新的 Promise。
- 成功条件:只有当所有输入的 Promise 都变为
fulfilled时,返回的 Promise 才变为fulfilled。- 结果顺序:返回的结果数组必须与输入数组的顺序一致,而不是完成的先后顺序。
- 失败条件:只要有一个输入的 Promise 变为
rejected,返回的 Promise 立即变为rejected,并携带第一个失败的原因(快速失败)。 - 空数组处理:如果输入是空数组,直接返回一个已 resolved 的空数组
[]。
通俗比喻:
Promise.all像是一个班主任,带着全班同学(一组 Promise)去体检。
- 只有当最后一个同学体检完且合格,班主任才会说“全班通过”(返回结果数组)。
- 如果任何一个同学体检不合格,班主任立刻大喊“全班不通过”,并记下是谁出了问题(抛出错误),不再等待其他人。
- 无论谁先体检完,最后交上来的成绩单顺序必须按学号(输入顺序)排列,不能乱。
📂 目录
- 🏗️ 实现思路拆解
- 💻 逐步代码实现
- ⚠️ 关键细节:为什么需要计数器?
- 🧪 测试与验证
- 💡 总结
1. 🏗️ 实现思路拆解
要实现Promise.all,我们需要解决三个核心问题:
- 如何并行执行?
- 遍历输入数组,立即启动所有的 Promise(或将其包装为 Promise)。
- 如何收集结果并保持顺序?
- 创建一个与输入等长的结果数组
results。 - 每个 Promise 完成后,将结果填入对应的索引位置
results[index] = value。
- 创建一个与输入等长的结果数组
- 如何知道所有任务都完成了?
- 使用一个计数器count。每完成一个任务,
count++。 - 当
count === 输入数组长度时,调用resolve(results)。
- 使用一个计数器count。每完成一个任务,
2. 💻 逐步代码实现
第一步:基础骨架
functionmyPromiseAll(promises){returnnewPromise((resolve,reject)=>{// 1. 校验输入是否为可迭代对象(简化版只处理数组)if(!Array.isArray(promises)){returnreject(newTypeError("Argument is not iterable"));}// 2. 处理空数组情况if(promises.length===0){returnresolve([]);}// 3. 准备容器constresults=newArray(promises.length);// 保持长度,初始为 emptyletcompletedCount=0;// 计数器// 4. 遍历执行promises.forEach((item,index)=>{// ... 核心逻辑在这里});});}第二步:处理每个元素(核心逻辑)
我们需要处理两种情况:
item本身不是 Promise(如数字、字符串):直接视为成功。item是 Promise:监听其状态。
为了统一处理,我们可以使用Promise.resolve(item)。它会将普通值包裹成 resolved 的 Promise,如果是 Promise 则原样返回。
promises.forEach((item,index)=>{// 统一包装,确保它是 PromisePromise.resolve(item).then((value)=>{// ✅ 成功逻辑results[index]=value;// 1. 存入对应位置completedCount++;// 2. 计数加 1// 3. 检查是否全部完成if(completedCount===promises.length){resolve(results);}}).catch((reason)=>{// ❌ 失败逻辑:快速失败reject(reason);});});第三步:完整代码整合
/** * 手写实现 Promise.all * @param {Array} promises - Promise 数组或包含普通值的数组 * @returns {Promise} */functionmyPromiseAll(promises){returnnewPromise((resolve,reject)=>{// 1. 非数组校验if(!Array.isArray(promises)){returnreject(newTypeError(`The argument${promises}is not an array`));}// 2. 空数组直接返回if(promises.length===0){returnresolve([]);}constresults=[];letcompletedCount=0;// 3. 遍历执行for(leti=0;i<promises.length;i++){// 使用 Promise.resolve 兼容普通值Promise.resolve(promises[i]).then((value)=>{results[i]=value;// 保持顺序的关键:直接赋值给索引 icompletedCount++;// 当所有任务都完成时,resolve 整个结果数组if(completedCount===promises.length){resolve(results);}}).catch((err)=>{// 只要有一个失败,立即 rejectreject(err);});}});}3. ⚠️ 关键细节:为什么需要计数器?
很多初学者会问:“为什么不直接用results.length来判断?”
// ❌ 错误做法results.push(value);if(results.length===promises.length){...}原因:push是按完成顺序添加的,而Promise.all要求结果按输入顺序排列。
- 假设 P1 耗时 3秒,P2 耗时 1秒。
- P2 先完成,如果用
push,results[0]会是 P2 的结果。 - 但正确的结果应该是
results[0]是 P1,results[1]是 P2。
正确做法:
预先创建一个固定长度的数组(或使用new Array(len)),然后通过索引赋值results[i] = value。这样无论谁先完成,都会乖乖待在属于自己的位置上。
4. 🧪 测试与验证
让我们用几个场景来验证我们的实现。
场景一:正常成功
constp1=newPromise((resolve)=>setTimeout(()=>resolve(1),1000));constp2=newPromise((resolve)=>setTimeout(()=>resolve(2),2000));constp3=3;// 普通值myPromiseAll([p1,p2,p3]).then((res)=>{console.log(res);// 输出: [1, 2, 3]// 注意:虽然 p3 最快,p1 次之,但顺序严格遵循输入数组});场景二:快速失败
constp1=newPromise((_,reject)=>setTimeout(()=>reject("Error 1"),1000),);constp2=newPromise((resolve)=>setTimeout(()=>resolve(2),2000));myPromiseAll([p1,p2]).then((res)=>console.log(res)).catch((err)=>{console.error(err);// 输出: "Error 1"// p2 还在运行,但 myPromiseAll 已经结束了});场景三:空数组
myPromiseAll([]).then((res)=>{console.log(res);// []});5. 💡 总结
| 关键点 | 实现方式 |
|---|---|
| 兼容性 | 使用Promise.resolve()包装每个元素,兼容普通值 |
| 顺序保证 | 使用results[index] = value而非push |
| 完成判断 | 使用completedCount计数器,避免依赖数组长度 |
| 快速失败 | 在.catch中直接调用reject() |
| 边界处理 | 检查输入是否为数组,处理空数组情况 |
🚀 博主寄语:
手写Promise.all不仅是为了应付面试,更是为了理解并发控制和状态管理的本质。
当你理解了如何通过计数器和索引来协调多个异步任务,你就掌握了处理复杂异步流的一把钥匙。记住口诀:
All 方法返新包,
遍历输入逐个跑。
Resolve 包装保兼容,
索引赋值序不乱。
计数累加判结束,
一人出错全完蛋。
希望这篇文档能帮你彻底掌握Promise.all的实现原理!如果有疑问,欢迎在评论区留言。👇
喜欢这篇文章吗?记得点赞、收藏、转发哦!❤️