再也不怕"函数包裹"题:从 fetchWithRetry 到 withRetry / withLimit / withDelay 全解析

0 阅读8分钟

面试那道 fetchWithRetry 的题,我当时脑子里一片空白。

不是不会写 fetch,不是不理解"重试"的概念,但就是不知道代码该从哪里开始——function fetchWithRetry 之后,下一行该写什么?

后来我才意识到:这类题的难点不是逻辑,是心智模型没建立起来。一旦你理解了"函数包裹"这件事的本质,withRetrywithLimitwithDelay,以及它们的任意组合,都会变成同一道题的不同变体。

这篇文章是我梳理这个知识点的完整过程,希望对遇到同类题目时感到犯怵的你有所帮助。


先建立心智模型:这类题在问什么?

"函数包裹"题的本质,是高阶函数(Higher-Order Function)+ 控制流管理

它们有一个共同的结构:

原始能力(一个 async 函数)
    ↓ 被包裹
增强能力(重试 / 限流 / 延迟)
    ↓ 对外暴露
与原始函数相同的调用接口

注意最后一点:"与原始函数相同的调用接口"。这是关键——包裹之后,调用方感知不到包裹的存在。这其实是装饰器模式(Decorator Pattern)的思想。

理解了这个结构,再看题目时,你要做的第一件事就是问自己:

这个包裹需要在哪个环节拦截?拦截之后做什么?最终还是要交回原始函数去执行。


Part 1:fetchWithRetry —— 最经典的起点

题目描述

实现 fetchWithRetry(url, options, retries),在请求失败时自动重试最多 retries 次。

先想清楚,再动手

失败时重试,意味着:

  1. 先正常执行一次 fetch
  2. 如果成功 → 直接返回结果
  3. 如果失败 → 判断还有没有剩余次数
  4. 有剩余 → 递归或循环再执行一次
  5. 次数耗尽 → 把最后一次的错误抛出去

这是一个天然适合递归的场景,因为每次重试本质上是"带着更少的剩余次数,重新做同一件事"。

// 环境:浏览器 / Node.js 18+
// 场景:带自动重试的 fetch 封装
async function fetchWithRetry(url, options = {}, retries = 3) {
  try {
    const response = await fetch(url, options);
    
    // fetch 本身在网络层面不会抛错,HTTP 4xx/5xx 需要手动判断
    if (!response.ok) {
      throw new Error(`HTTP Error: ${response.status}`);  // ← 这里 throw
    }
    
    return response;  // 成功时返回
    
  } catch (err) {     // ← throw 会到这里
    if (retries <= 0) {
      throw err;      // 重试耗尽,抛给外部调用者
    }
    return fetchWithRetry(url, options, retries - 1);  // 递归重试
  }
}

// usage
fetchWithRetry('https://api.example.com/data', {}, 3)
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error('All retries failed:', err));

有几个容易踩的坑值得注意:

坑 1:fetch 失败和请求失败是两回事。
网络断开时 fetchreject,但服务器返回 500 时,fetchresolve 的——只是 response.okfalse。如果你只 catch 不检查 response.ok,500 错误不会被重试。

坑 2:递归的终止条件要写对。
retries <= 0 而不是 retries === 0,防御性更强。

坑 3:最后一次失败要把错误抛出去。
调用方需要知道"所有重试都失败了",所以 throw err 不能省。

在 try 中的这个 throw 会进入 同一个 try...catch 结构里的 catch,而不是直接作为最终结果抛出去。

关键点:

情况结果
response.ok === falsethrow当前函数的 catch 捕获
catchretries > 0递归调用自身,发起重试
catchretries <= 0throw err 抛给外部调用者

简单记忆:

throw → 最近的 try...catch 捕获 → 没捕获再往外抛

这里 throw new Error(...)catch同一层的,所以一定会被接住。只有 catch 里的那个 throw err 才是真正对外抛的。

这也解释了为什么这个设计能工作:HTTP 错误被转成异常,走统一的重试/失败逻辑,和网络断开时的 fetch reject 行为一致。


Part 2:从具体到通用 —— withRetry 高阶函数

fetchWithRetry 解决了具体问题,但如果我有一个 queryDatabase 函数,也想让它支持重试呢?难道要再写一个 queryDatabaseWithRetry

这时候就需要把"重试"这个能力提取出来,变成一个通用的包裹函数

核心思路

withRetry(fn, retries) → 返回一个新函数
新函数的签名和 fn 完全相同
调用新函数时,内部自动处理重试逻辑
// 环境:浏览器 / Node.js 18+
// 场景:通用重试高阶函数

function withRetry(fn, retries = 3) {
  // return a wrapper that has the same signature as fn
  return async function (...args) {
    try {
      return await fn(...args);
    } catch (err) {
      if (retries <= 0) throw err;
      // create a new wrapper with retries - 1, call it with the same args
      return withRetry(fn, retries - 1)(...args);
    }
  };
}

// usage — wrap any async function
const resilientFetch = withRetry(fetch, 3);
const resilientQuery = withRetry(queryDatabase, 5);

resilientFetch('https://api.example.com/data')
  .then(res => res.json())
  .catch(err => console.error('Failed after retries:', err));

注意 ...args 的使用——这是让包裹函数"透明"的关键。包裹层不需要知道原函数接受什么参数,只负责透传。


Part 3:withDelay —— 在时间维度上的控制

题目描述

实现 withDelay(fn, delay),让函数调用延迟 delay 毫秒后执行。

这比 withRetry 简单一些,核心是封装一个 sleep 工具函数。

// 环境:浏览器 / Node.js 18+
// 场景:延迟执行包裹函数

// sleep utility
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

function withDelay(fn, delay = 1000) {
  return async function (...args) {
    await sleep(delay);
    return fn(...args);
  };
}

// usage
const delayedFetch = withDelay(fetch, 2000);
delayedFetch('https://api.example.com/data'); // executes after 2s

延伸变体:withRetry + 指数退避(Exponential Backoff)

在实际场景中,重试往往需要配合延迟——立刻重试可能会让已经超载的服务更糟糕。指数退避是一种常见策略:第 1 次重试等 1s,第 2 次等 2s,第 3 次等 4s……

// 场景:带指数退避的重试(更接近生产实践)

function withRetryAndBackoff(fn, retries = 3, baseDelay = 1000) {
  return async function (...args) {
    let attempt = 0;

    while (attempt <= retries) {
      try {
        return await fn(...args);
      } catch (err) {
        if (attempt === retries) throw err;

        const delay = baseDelay * Math.pow(2, attempt);
        await sleep(delay); // 1s, 2s, 4s ...
        attempt++;
      }
    }
  };
}

这里我换成了 while 循环写法,个人觉得比递归更直观地表达了"尝试次数"的概念,两种方式都是合理的。


Part 4:withLimit —— 并发控制

题目描述

实现 withLimit(fn, limit),限制函数的最大并发执行数为 limit

这道题的难度上了一个台阶,因为它需要跨多次调用维持状态

想清楚状态是什么

并发控制需要知道:

  • 当前有多少个调用正在执行(running 计数器)
  • 当超出限制时,新的调用要排队等待(queue
  • 当某个调用完成时,要从队列中取出下一个来执行
// 环境:浏览器 / Node.js 18+
// 场景:并发数量限制的高阶函数

function withLimit(fn, limit = 3) {
  let running = 0;
  const queue = []; // { args, resolve, reject }

  // this is the core scheduler
  function schedule() {
    while (running < limit && queue.length > 0) {
      const { args, resolve, reject } = queue.shift();
      running++;

      fn(...args)
        .then(resolve)
        .catch(reject)
        .finally(() => {
          running--;
          schedule(); // when one finishes, try to run the next
        });
    }
  }

  return function (...args) {
    return new Promise((resolve, reject) => {
      queue.push({ args, resolve, reject });
      schedule();
    });
  };
}

// usage
const limitedFetch = withLimit(fetch, 2); // at most 2 concurrent fetches

const urls = [
  'https://api.example.com/1',
  'https://api.example.com/2',
  'https://api.example.com/3',
  'https://api.example.com/4',
];

Promise.all(urls.map(url => limitedFetch(url)));
// only 2 fetches run at a time; others wait in queue

这里有一个设计决策值得思考:状态(runningqueue)保存在 withLimit 的闭包里,而不是每次调用时创建。这意味着所有通过 limitedFetch 发出的请求共享同一个计数器,这才能实现真正的全局并发控制。


Part 5:组合使用 —— 真实场景下的叠加

面试题可能会问:"如果我既想要重试,又想要并发限制,又想要延迟,怎么办?"

一种思路是手动组合:

// 场景:组合多个 wrapper

let enhancedFetch = fetch;
enhancedFetch = withLimit(enhancedFetch, 5);       // max 5 concurrent
enhancedFetch = withRetryAndBackoff(enhancedFetch, 3, 500); // retry 3 times with backoff

// now enhancedFetch is limited + retried + delayed
enhancedFetch('https://api.example.com/data');

但这有一个问题:组合的顺序很重要,理解起来需要一定心智负担。

另一种更优雅的方式是实现一个 composepipe

// 函数式组合工具
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);

// each wrapper is partially applied with its config
const withLimit3 = (fn) => withLimit(fn, 3);
const withRetry3 = (fn) => withRetry(fn, 3);
const withDelay1s = (fn) => withDelay(fn, 1000);

const enhancedFetch = pipe(withLimit3, withRetry3, withDelay1s)(fetch);

这种方式让每个包裹层的配置和组合逻辑分离,可读性更好。


面试快速参考

遇到这类题时,可以用这个思路框架快速定位:

题目类型核心状态控制流关键词容易忘的细节
withRetry剩余次数try/catch + 递归 or 循环response.ok 检查;最后一次要 throw
withDelayawait sleep(ms)sleep 是 Promise wrapper
withLimitrunning 计数 + 队列.finally() 调度状态要在闭包外;schedule 要在 finally 中调用
组合各自状态嵌套包裹 or compose/pipe顺序影响语义

延伸思考

梳理完这些之后,我反而产生了一些新的问题:

  1. withLimit 的队列是 FIFO 的。如果需要优先级队列(VIP 请求优先执行),结构会怎么变?
  2. 这些包裹函数都没有处理"取消"(abort)。如果调用方想取消排队中的请求,应该怎么暴露接口?
  3. withRetry 对所有错误都重试,但有些错误(比如 401 鉴权失败)是不应该重试的——如何让调用方定义"哪些错误才需要重试"?

这些问题可能在更深入的面试或实际项目中遇到,目前先记录下来作为后续探索的方向。


小结

回头看,当初面对 fetchWithRetry 卡壳的原因很清楚了——我没有意识到这是一类有固定结构的题,试图在没有模型的情况下"硬想"具体实现。

一旦理解了"函数包裹 = 接受函数 → 返回函数 → 内部拦截控制流 → 透传参数和结果"这个结构,剩下的就是填充具体的控制逻辑。

这类题的本质是装饰器模式在异步函数上的应用,而它考察的核心能力,是你能不能把"重试这件事"和"这次具体请求的数据"分开思考。

希望这篇文章对你有帮助。如果你有不同的实现思路或者遇到了更复杂的变体,欢迎交流。


参考资料