- 前端
- 文档
- 教程
- 技术博客
【免费下载链接】all-of-frontend
你想知道的前端内容都在这
导读
本文围绕 all-of-frontend 仓库 Answer/1 ~ 10/5.md 记录的经典前端面试题展开:多个异步请求通过Promise.all并发处理时,如何让其中失败的所有请求自动重试。文章先剖析Promise.all的 fail-fast 语义与"必须在请求函数内部 catch"的核心结论,再逐行拆解标准答案,并从 Promise 状态采纳、异步递归、隐式类型转换等底层原理深入,最后给出基于 axios 拦截器的工程化重试封装与Promise.allSettled备选方案。读完你将既能答对这道面试题,也能把"失败重试"真正落地到业务请求层。
题目回顾:Promise.all 场景下的失败重试
原题描述非常精炼,核心诉求是"让失败的所有请求重试":
Promise.all([A, B, C, D]) // 4 个请求完成后发现 AD 请求失败了,如果让 AD 请求重试注意题目强调的是**"失败的所有请求"**——也就是失败的请求不止一个时(如上例中的 A 和 D),每个失败请求都要各自重试,而不是重发整个批次。这与"整体重试"有本质区别,也是很多错误答案的根源所在。
核心分析:为什么必须在请求函数内部处理 catch
Promise.all 的 fail-fast 语义
要理解这道题,首先得清楚Promise.all的底层行为:
Promise.all接收一个可迭代的 Promise 集合,并返回一个新的 Promise;- 只有当所有Promise 都变为 fulfilled 时,结果才会 resolve,且结果数组顺序与传入顺序一致;
- 只要任意一个Promise 变为 rejected,
Promise.all返回的 Promise 就会立刻以该错误 reject(即 fail-fast),其余尚未完成的请求结果被丢弃。
所以题目的第一句结论是:一个 Promise 在Promise.all中"挂了就挂了"——Promise.all本身不会给你任何重试机会,它只会立刻把失败结果抛给下游。既然如此,重试逻辑就绝不可能放在Promise.all的外部(比如给它整体挂一个.catch后重发全部请求),因为那样会把已经成功的请求也重新请求一遍,既浪费流量,又可能产生重复副作用(重复下单、重复写库等)。
常见错误解法:在 resolve 里判断重试
原文档明确指出,网上部分答案"结果是对的,但处理方式是错误的",典型代表是:在resolve中判断是否需要重试。例如有人会写类似下面的伪逻辑:
new Promise((resolve) => { request().then((data) => { if (需要重试的条件) { return request(); // 再发一次 } resolve(data); }); });这个思路错在哪?原文档给了一针见血的解释:业务中的请求基本都是封装过的函数,出错时一定走reject,根本不可能resolve出一个"错误"来。
换句话说,一个遵循 Promise 语义的请求封装,成功与失败只有resolve/reject两条出路,错误信息通过 rejection reason 传递。如果某个封装把失败也resolve出来(比如resolve({ code: 500 })),那它本身就是反模式——调用方无法用统一的.catch处理错误,接入第三方库、做错误监控时都会非常别扭。因此在resolve里做"假成功"判断,既不符合封装约定,也无法覆盖真实业务中封装函数直接reject的情况。
正确的拦截点只有一个:请求函数内部的.catch。因为每个请求的失败在封装内部就被拦截并重试,Promise.all拿到的永远是这个请求的"最终结果"(成功,或重试耗尽后的失败)。
标准解法:在请求封装内通过 catch 链式重试
完整代码
原文档给出的标准答案(摘自社区 Issue 的优秀作答)如下:
function request(name, count = 0) { return new Promise((resolve, reject) => { const isSuccess = Math.random() > 0.5; console.log(`接口${name}: ${isSuccess}`); setTimeout(() => { isSuccess > 0.5 ? resolve(name) : reject(name); }, Math.random() * 1000); }).catch((err) => { count++; if (count > 2) { return Promise.reject(`后端大爷${name}接口写的666`); } return request(name, count); }); } let queue = [request('A'), request('B'), request('C'), request('D')]; Promise.all(queue) .then((arr) => { console.log(arr); }) .catch((err) => { console.log(err); });代码拆解
这份代码的骨架可以提炼为三层:
- 模拟请求层:
request(name, count)用一个Promise模拟异步接口,Math.random() > 0.5决定本次请求成败,setTimeout模拟网络耗时。这是面试演示用的模拟实现,真实业务中这里应替换为axios/fetch等真实请求。 - 重试层:
Promise链末尾挂的.catch((err) => {...})是整道题的灵魂。任何一次失败都会进入这里,然后做两件事:count++累计失败次数;- 若
count > 2,说明重试次数已达上限,返回Promise.reject(...)把最终失败抛给Promise.all; - 否则
return request(name, count)带着新的计数重新发起请求。
- 聚合层:
Promise.all(queue)等待四个请求的最终结果——注意,是每个请求内部完成重试之后的结果。全部成功则then输出结果数组,任一请求重试耗尽仍失败则catch收到错误。
执行流程推演
以"A、D 首次请求失败"为例,完整流程是:
- A、D 首次失败后分别进入各自的
.catch,count从 0 变为 1,随即发起第二次请求; - 若 A 第二次仍失败,
count变为 2,继续发起第三次请求(此时重试次数还没超过上限); - 若 A 第三次仍失败,
count变为 3,触发count > 2,A 的最终状态变为 rejected,Promise.all收到该错误并整体 reject; - 若 D 第二次成功,则 D 正常 resolve;
- 只要四个请求的最终结果全部 resolve,
Promise.all的then才会执行,输出['A', 'B', 'C', 'D']。
由此也回答了题目本身:"失败的所有请求"是如何被覆盖的——重试逻辑写在每个请求自己的封装里,A、D 各自独立重试,互不影响。这正是该方案相比"整体重发"的本质优势。
深入原理:catch 返回的 Promise 如何驱动重试
Promise 状态采纳
这道题能成立,依赖的是 Promise 的**状态采纳(assimilation)**机制:.catch(onRejected)会返回一个新的 Promise,当onRejected内部返回一个 Promise 时,外层 Promise 的状态会被"采纳"——也就是说,.catch里return request(name, count)之后,外层 promise 会跟随重试请求的状态走:重试成功则整体 resolve,重试失败则整体 reject,重试返回的 promise 再 reject 又会再次进入下一个.catch…… 这正是链式重试能够循环往复的底层原理。Promises/A+ 规范与 ECMAScript 的 Promise 语义均规定了这一行为,仓库 README.md 的 Promise 学习路径里也收录了 Promises/A+ 规范原文与手写实现(20 行极简版、100 行规范版)等资料,可对照研读。
异步递归不会爆栈
return request(name, count)表面看是递归调用自身,但它发生在.catch回调里,且每次重试都经由setTimeout异步调度、每次都新建 Promise。也就是说这是异步递归,重试 N 次只是多挂了几层微任务/宏任务链,并不会在同步调用栈上层层累积,因此不存在递归深度导致栈溢出的问题。读者如果担心这点,可以放心。
一个容易被忽略的细节:布尔值与数值比较
细心的读者可能注意到,原代码里isSuccess是布尔值(Math.random() > 0.5的结果),但判断条件写的却是isSuccess > 0.5。这在语法上是合法的:JS 在做布尔与数值比较时,会把布尔值隐式转换为 0/1,即true > 0.5等价于1 > 0.5,false > 0.5等价于0 > 0.5。所以这段代码碰巧能正确工作,但可读性并不好,面试或落地时建议直接写成isSuccess ? resolve(name) : reject(name)。顺带一提,重试上限的判断count > 2意味着:每个请求最多重试2次、总尝试3次,这个阈值应根据业务容忍度调整,而不是写死。
工程化扩展:把重试能力封装进请求层
面试题之外,真正的价值在于把"失败重试"做成请求层的通用能力。仓库 package.json 声明了axios ^0.21.1依赖,说明请求封装是这类场景的标配基础设施,下面给出基于 axios 拦截器的通用重试封装示例(通用实践模式,可直接复制改造)。
重试策略三要素
工程化的重试绝不是简单的"失败就再来一次",至少要决策三件事:
- 最大重试次数(maxRetries):决定容忍几次失败,防止无限重试拖垮系统;
- 重试间隔(delay):立即重试往往与首次失败原因高度相关(如瞬时网络抖动),适度等待能提高成功率;
- 退避策略(backoff):固定延迟简单直接;指数退避(每次延迟翻倍)更符合"故障持续期未知"的实际情况;再加上随机抖动(jitter),可以避免大量请求在同一时刻集中重试造成雪崩。
一个典型的指数退避 + 抖动实现:
function backoffDelay(attempt, base = 500, max = 10000) { const exp = Math.min(base * Math.pow(2, attempt), max); return exp / 2 + Math.random() * (exp / 2); // 加随机抖动 }axios 拦截器版重试封装
把重试挂在响应拦截器的错误分支上,业务侧调用方式零改动:
function createRequestWithRetry(maxRetries = 2, baseDelay = 500) { const instance = axios.create(); instance.interceptors.response.use( (res) => res, (err) => { const config = err.config || {}; // 用自定义字段记录当前请求已重试次数 config.__retryCount = (config.__retryCount || 0) + 1; if (config.__retryCount > maxRetries || !shouldRetry(err)) { return Promise.reject(err); } const delay = backoffDelay(config.__retryCount, baseDelay); return new Promise((resolve) => setTimeout(resolve, delay)).then(() => instance(config) // 重新发起原请求 ); } ); return instance; }配套的shouldRetry决定哪些错误值得重试:
function shouldRetry(err) { if (err.code === 'ECONNABORTED') return true; // 超时 if (!err.response) return true; // 网络层错误(无响应) const status = err.response.status; return status >= 500 && status < 600; // 5xx 服务端错误 // 4xx 属于客户端参数问题,重试无意义,默认不重试 }重试的边界与约束
工程上重试必须考虑副作用问题:只有幂等请求(查询类 GET、幂等键保护的写操作)才适合自动重试;对于下单、转账等非幂等写操作,自动重试可能造成重复提交,应改为人工确认或配合幂等键。此外,重试会放大请求量,务必设置总重试次数与退避上限,防止服务端故障时客户端疯狂重试。
另一条路线:Promise.allSettled 收集失败再重试
题目给的是"每个请求内部各自重试"的解法,但面试官常会追问"如果我不想改请求封装,有没有别的思路"。此时可以引出Promise.allSettled的备选方案:
async function runWithRetry(requests, maxRetries = 2) { let pending = requests.map((p) => p().catch((e) => ({ error: e }))); let attempt = 0; while (attempt < maxRetries) { const results = await Promise.allSettled(pending.map((p) => p)); const failed = results.filter((r) => r.status === 'rejected'); if (failed.length === 0) { return results.map((r) => (r.status === 'fulfilled' ? r.value : null)); } // 只对失败的请求重新发起 pending = failed.map((r) => r.reason()); attempt++; } throw new Error('重试次数耗尽'); }两种方案的取舍很清晰:
- 请求内部重试(题目标准解):重试逻辑与请求封装绑定,适合所有调用方都要重试的通用场景,但侵入封装;
- 外层收集重试(allSettled 路线):不改封装,重试策略由调用方按业务临时决定,代价是
Promise.allSettled会等待所有请求落定、失败项要自己维护索引与重发逻辑,代码更啰嗦。
回答时能讲清这两种路线及各自适用场景,比背答案更能体现功底。
验证思路与自测建议
原题代码用Math.random()模拟成败,运行结果不确定,只适合演示。要真正验证重试逻辑,建议把"成败来源"抽成可注入的参数,例如传入一个预先排好的成败序列,让测试可复现:
function createRequestWithOutcome(name, outcomes) { let index = 0; return function request(count = 0) { return new Promise((resolve, reject) => { const ok = outcomes[index++] ?? false; setTimeout(() => (ok ? resolve(name) : reject(name)), 10); }).catch(() => { if (count + 1 > 2) return Promise.reject(name); return request(count + 1); }); }; } // A:失败→失败→成功(第 3 次才成功);D:失败→成功 Promise.all([ createRequestWithOutcome('A', [false, false, true])(), createRequestWithOutcome('B', [true])(), createRequestWithOutcome('C', [true])(), createRequestWithOutcome('D', [false, true])(), ]) .then((arr) => console.log('成功:', arr)) // 应输出 ['A','B','C','D'] .catch((e) => console.log('失败:', e));自测时可以覆盖四类用例:全部首次成功、部分重试后成功、重试耗尽整体失败、失败请求数量大于 1(验证"所有失败请求各自重试")。若使用 Jest,可配合jest.useFakeTimers()固定setTimeout时序,让断言完全确定。
关联学习路径
本题是仓库"前端核心学习路径 → JS 核心知识点 → Promise"这一环的实战延伸。仓库 README.md 中 Promise 小节收录了 MDN 文档、Promises/A+ 规范及多篇手写 Promise 实现文章,建议把规范语义(状态采纳、fail-fast)与手写实现对照学习。仓库内还有多道 Promise 相关的姊妹题值得一并练习:
- Answer/1 ~ 10/4.md:点击按钮按顺序输出请求结果的队列题,利用
promise.then实现串行队列,与本题"并发 + 重试"互为对照; - Answer/1 ~ 10/1.md、Answer/1 ~ 10/2.md:
async函数内部错误与try/catch捕获范围的辨析,能帮你理解为什么"在async外层 catch 抓不到异步错误"; - Answer/11-20/17.md:
await与加法运算符求值顺序的陷阱,属于 async/await 语义的进阶补充。
把这些题目串起来,Promise、async/await、错误处理与并发控制这几块知识就形成了一个完整闭环,无论是面试还是日常请求层开发,都能做到知其然且知其所以然。
- 前端
- 文档
- 教程
- 技术博客
【免费下载链接】all-of-frontend
你想知道的前端内容都在这
相关推荐
all-of-frontend 面试题精讲:用 Promise 链式调用实现"并发请求、有序输出"的异步队列
all of frontend 面试题精讲:用 Promise 链式调用实现"并发请求、有序输出"的异步队列 导读:本文围绕 all of frontend 仓
前端文档教程技术博客Requests重试流程:自动重试失败请求
Requests重试流程:自动重试失败请求 在日常的网络请求中,我们经常会遇到各种不稳定的情况,比如网络波动、服务器临时不可用等,这些问题可能导致请求失败。如果
后端网络通信all-of-frontend 面试题精讲:this 默认绑定、arguments 对象与 window.length 的三重陷阱
all of frontend 面试题精讲:this 默认绑定、arguments 对象与 window.length 的三重陷阱 本文以 all of fro
前端文档教程技术博客
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考