ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写Promise + for循环:四道经典异步面试题与避坑指南

手写Promise + for循环:四道经典异步面试题与避坑指南 1. 为什么“手写Promise for循环”是面试杀手题前两天帮朋友模拟面试我出了一道很基础的题用for循环依次请求三个接口拿到结果后按顺序打印。这位朋友简历上写着“熟练掌握Promise”结果愣是写了十分钟最后还是用forEach草草收场。这个场景我见过太多次了。说句实话现在的前端面试背API用法已经完全骗不过面试官了。Promise相关的题目之所以成为必考题就是因为它能同时考察三样东西异步编程的理解深度、闭包和作用域的基本功、以及代码的边界意识。而“Promise for循环”这个组合尤其经典——你以为面试官在考你循环其实他在看你有没有踩过那些异步的坑。这篇文章我从四道经典手写题展开全部围绕for循环与Promise的交叉场景。它们几乎覆盖了国内大小厂的高频考法也是我实际面试别人时最喜欢用的题用for循环串行执行异步任务顺序控制手写Promise.all并发收集与结果保序手写Promise.retry循环里的中断与重试循环内发请求为什么全部拿到最后一个值闭包陷阱每一道我都给出可运行代码、逐行讲解和踩坑记录。不管你是准备面试的候选人还是带新人、做技术评审的老手这篇都值得花二十分钟读完。2. 第一道for循环串行执行异步任务2.1 题目描述与考察点先来看最基础的一道面试官通常这样出题现有若干个异步任务要求一次只执行一个前一个完成后再执行下一个最后返回所有结果。这道题考察的核心只有一个你是否知道for循环配合async/await能做到真正的“等待”。很多人会本能地想到forEach但恰恰是这个词把人带进坑里。2.2 参考实现与逐行拆解// 串行执行一组异步任务保留返回顺序 async function runTasks(tasks) { const results []; for (const task of tasks) { const result await task(); results.push(result); } return results; } // 使用示例 const tasks [ () fetch(/api/user).then((res) res.json()), () fetch(/api/orders).then((res) res.json()), () fetch(/api/cart).then((res) res.json()), ]; runTasks(tasks).then((data) { console.log(data); // 按数组顺序输出三个接口的结果 });核心就一个关键字await。当await task()执行时当前这个runTasks函数会暂停在这个位置Promise进入pending状态一直等task对应的Promise状态落定循环才继续到下一轮。所以for...of天然给你实现了“等一次走一步”的节奏。2.3 为什么forEach在这里不好使这是整道题的灵魂追问。我直接给结论forEach不能响应await的暂停语义。async function runTasksWrong(tasks) { const results []; tasks.forEach(async (task) { const result await task(); results.push(result); }); return results; // 到这里数组往往是空的 }原因在于forEach传入的是一个普通回调函数它本身不会等待回调里的异步操作。你虽然给回调加了async但forEach只管“把回调调起来”调用完立即继续遍历下一个元素。换句话说forEach发起的多个异步任务实际是并发的而且外层函数不会等待它们完成。从底层循环机制上看for...of是“迭代器协议驱动的循环”它会等待循环体内的await挂起和恢复而forEach是“立即遍历完所有元素的同步方法”它根本不认识Promise。这也是面试官希望听到的答案只答“forEach不支持await”不够要把机制讲透。提示如果面试官追问“那如何用forEach实现串行”可以这样回答——把forEach换成reduce或者手动构建一个链式Promise。但更推荐直接说“这种场景不该用forEach”把理由讲清楚更显功力。3. 第二道手写Promise.allfor循环保序不保命3.1 题目描述与考察点实现一个myAll(promises)传入一个Promise数组返回一个Promise。全部成功则以数组形式resolve任何一个失败立即reject。这道题在面试里出现的频率高到离谱但它从来不满足于你背出原生API。考察点有两个一是并发收集结果的正确性二是结果顺序与输入顺序的一致性。先说清楚一个关键概念Promise.all是并发执行的所有传入的Promise在创建那一刻就已经开始跑了。手写all不是在控制它们何时启动而是在收集每个Promise的完成结果。3.2 参考实现for循环拆解版function myAll(promises) { return new Promise((resolve, reject) { // 边界传入的不是数组 if (!Array.isArray(promises)) { return reject(new TypeError(promises must be an array)); } const results new Array(promises.length); let remaining promises.length; // 边界空数组直接resolve if (remaining 0) { return resolve(results); } for (let i 0; i promises.length; i) { // 用Promise.resolve统一包装兼容普通值 Promise.resolve(promises[i]).then( (value) { results[i] value; remaining--; if (remaining 0) { resolve(results); } }, (reason) { reject(reason); } ); } }); }这段代码里有三个细节很重要第一results用new Array(promises.length)预分配长度然后通过索引results[i] value写结果。这样即使某个Promise先完成它的值也只会落在自己的索引上最终返回的数组顺序一定和输入顺序一致。第二用Promise.resolve(promises[i])包一层。传入all的可能是普通数字、字符串、甚至thenable对象。Promise.resolve能把它们统一变成Promise避免后续处理报错。第三用remaining计数而不是直接判断results.every(...)。因为并发场景下最后一个完成的Promise知道自己之前已经完成了几个用它判断“是否全部完成”最精确。这里remaining 0表示所有Promise都已落定。3.3 最容易翻车的“结果顺序”问题很多人写这个题的时候会用results.push(value)心想“反正都收集到了”。错顺序会乱。并发请求的完成时间本来就不可控哪个先完成就把值推到数组末尾最终数组顺序就变成了“完成顺序”而不是“输入顺序”。举个例子[请求A(耗时1s), 请求B(耗时50ms)]如果按push写法返回结果是[B, A]用户期待的原生Promise.all结果应该是[A, B]。这个区别在业务里可能会直接造成数据错位。顺带说一句这里用for循环而不是forEach除了风格统一还有个实际收益——当数组极大时for循环在V8引擎下的优化空间比forEach的“函数回调 额外参数绑定”更好。面试时提到这一点属于加分项。4. 第三道手写Promise.retryfor循环里的中断与重试4.1 题目描述与考察点实现一个retry函数接受一个异步任务、重试次数、重试间隔。任务成功则直接返回结果失败则等待间隔后重试直到重试次数用完抛出最后一次的错误。这道题看起来比前两道难其实思路非常清晰它就是for循环的“正常执行 条件中断”逻辑。考察点在于能否熟练用for循环控制异步重试并且正确处理成功提前退出和最终抛错的边界。很多候选人会先想到递归把“重试”写成“失败后再次调用自身”。技术上没错但可读性差、调用栈深面试官更希望看到用for循环实现的迭代版本。4.2 参考实现async/await版本// 工具函数延迟指定毫秒 function sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)); } function retry(fn, times 3, delay 1000) { return async function wrapper(...args) { let lastError; for (let i 1; i times; i) { try { // 成功直接返回相当于break return await fn(...args); } catch (error) { lastError error; // 最后一次失败不再等待直接进入下一轮抛错 if (i times) { await sleep(delay); } } } throw lastError; }; } // 使用示例 const requestWithRetry retry( () fetch(/api/risk-control).then((res) { if (!res.ok) throw new Error(request failed); return res.json(); }), 3, 500 ); requestWithRetry().then(console.log).catch(console.error);逐行拆解一下这个实现for (let i 1; i times; i)这里从1开始计数是为了后面判断i times更直观——当i等于3时已经是第三次不需要再sleep等待直接往后走到throw lastError。return await fn(...args)是关键。一旦成功函数直接返回循环结束。这本质上是for循环的“提前退出”用法很多人只记得break忘了return也能在包裹函数里终止整个循环。throw lastError在循环结束后执行意味着前面的所有尝试都失败了。注意lastError要用let声明在循环外否则循环结束后变量就丢了。这个细节我面试时遇到不少候选人没注意到。4.3 重试间隔与失败兜底的细节设计实际项目里重试往往不是死等固定间隔通常会配合“指数退避”即每次重试的等待时间递增1s、2s、4s……你只需要把sleep参数改成delay * Math.pow(2, i - 1)即可。另一个很实际的问题是重试场景里最常见的还是请求类任务网络异常、接口超时都值得重试。但要注意不是所有错误都该重试。如果是后端返回的业务错误码比如“参数格式不对”“无权限”重试一百次结果也一样。更合理的写法是在catch里判断错误类型catch (error) { if (error.code PARAM_INVALID) throw error; // 不可重试直接抛 lastError error; }面试时如果能把这一层考虑进去说明你真的在真实项目里写过重试而不是背模板。5. 第四道for循环里发请求为什么全部拿到最后一个值5.1 题目描述老生常谈的“闭包陷阱”这道题是四道里最像一个“陷阱”的考察的既有作用域知识也有异步时序理解。题目通常是这样的// 现象打印出来的id全部是5 for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 1000); }放到Promise场景里就变成for (var i 0; i 5; i) { fetch(/api/comments?id${i}) .then((res) res.json()) .then((data) { // 期望打印 0 1 2 3 4实际全部打印 5 console.log(评论${i}:, data); }); }注意一个容易混淆的细节这里发的5个请求本身是正确的。因为fetch(/api/comments?id${i})里的i是在循环同步执行阶段取值的那一刻i分别是0、1、2、3、4。真正出问题的是then回调里的console.log(i)——它等到异步回调执行时循环早已结束i已经是5了。5.2 原因拆解var、闭包、微任务的三角关系用一句话解释var声明的i是函数级作用域整个for循环共用同一个i而then回调里的箭头函数对这个i形成了闭包引用的是“同一个变量”。当微任务队列里的回调开始执行时for循环的同步代码已经全部跑完i的最终值是5所以所有回调都看到5。很多人把锅甩给“异步”其实本质是共享可变变量 延迟读取的问题。即使没有异步只靠闭包也能构造出类似的bugconst fns []; for (var i 0; i 5; i) { fns.push(() console.log(i)); } fns[0](); // 55.3 修复方案对比与面试作答建议修复方案有四种按推荐程度排序方案一var改let一行解法for (let i 0; i 5; i) { fetch(/api/comments?id${i}) .then((res) res.json()) .then((data) console.log(评论${i}:, data)); }let给每次循环创建了独立的块级作用域每次迭代的i都是新变量闭包捕获的也分别是不同变量。这是最干净的做法ES6时代首推。方案二立即执行函数包裹兼容旧环境for (var i 0; i 5; i) { (function (index) { fetch(/api/comments?id${index}) .then((res) res.json()) .then((data) console.log(评论${index}:, data)); })(i); }利用函数参数按值传递的特性把当前i的副本交给内层作用域。面试官问“如果不用let怎么办”答这个。方案三forEach代替for循环[0, 1, 2, 3, 4].forEach((index) { fetch(/api/comments?id${index}) .then((res) res.json()) .then((data) console.log(评论${index}:, data)); });forEach回调的index参数本身就是每次调用时的独立副本天然避免共享变量问题。方案四把需要的值存到外部数组里回调只读取下标对应的缓存。这个方案更绕面试一般不推荐但写业务代码时偶尔能用上。面试时建议按“原因分析 - 方案对比 - let的底层语义”这个逻辑去答。不要只报答案“用let”一定要能说出var和let在作用域上的本质差异。6. 常见问题与避坑实录6.1 高频错误速查表问题现象根本原因处理方式forEach里用await任务全部同时执行forEach回调不等待异步完成换成for...of或手动reduce链手写Promise.all返回顺序是乱的用push收集结果没用索引写入预分配数组 索引赋值Promise.all传入空数组一直pending忘记处理长度为0的边界先判断remaining为0则立即resolvevar循环发请求回调全打印最后一个值闭包捕获同一个变量let / IIFE / forEachretry失败后立即重试可能加剧故障没有间隔或退避加入sleep和指数退避手写all中某个Promise reject后剩余还在执行原生all特性如此不影响结果面试中说明reject后结果已定剩余任务无法取消6.2 面试答题的节奏建议这几道题都到手写环节了节奏就是“先讲原理、再写代码、最后补边界”。开场先花30秒说清楚Promise核心机制——三种状态pending、fulfilled、rejected一旦落定不可再变then的回调进入微任务队列。这相当于给面试官一个信号我知道自己在写什么。写代码时不要闷头写最好边写边讲。比如写到Promise.resolve(item)时说一句“这里兼容一下非Promise值”写到索引赋值时说一句“用索引保证顺序和输入一致”。每句话都是加分项。代码写完之后主动补边界空数组、非Promise值、类型校验。面试官最反感的是那种“写出来就完事了”的候选人主动聊边界问题是区分初中级和前端的有效信号。还有个小技巧如果时间允许可以提到原生Promise.allSettled、race和all的差异。这能展示你的知识面不止于死记API而是对整个Promise家族有系统认知。7. 写在最后一道更进阶的题留给你面试过太多次之后我自己有个体会手写题真正想筛掉的是“没踩过坑”的人。API文档里的写法人人都会背但代码里的坑只有把你扔进真实业务里炸一回才会长记性。上面四道题全部踩过之后我建议你再练一道进阶题实现一个带并发限制的异步调度器Scheduler最多同时跑两个任务自动从等待队列补充新任务。它本质上就是for循环思维 队列操作的组合也是我在实际项目中封装请求并发控制时最常写的模式。如果你把这道题也吃透了那Promise手写这一类面试题对你来说就彻底通关了。
返回列表