1. 项目概述:当“装饰”遇上“承诺”

在JavaScript的异步编程世界里, Promise 对象是我们处理异步操作的核心工具,它代表了一个最终可能完成(或失败)的操作及其结果值。而“装饰器”(Decorator)作为一种设计模式,其核心思想是在不改变原对象结构的前提下,动态地为其添加新的功能或行为。那么,一个自然而然的问题就产生了:我们能否像装饰一个普通函数或类那样,去装饰一个 Promise 呢?更进一步,在装饰的过程中,我们如何确保不破坏 Promise 本身的核心契约——即它的状态(pending, fulfilled, rejected)和链式调用(then/catch/finally)的完整性?

这个项目标题“Decorating Promises Without Breaking Them”精准地指向了JavaScript中一个既实用又充满陷阱的高级主题。它探讨的不是简单地使用 Promise ,而是如何以更高级、更优雅的方式去“包装”或“增强” Promise ,同时保证其原有的、被广泛依赖的行为特性不被破坏。这听起来像是一个纯粹的学术问题,但实际上,它在构建健壮、可维护的现代前端应用、Node.js服务端应用乃至任何重度依赖异步流程的JavaScript项目中,都扮演着至关重要的角色。

想象一下这些场景:你需要为所有发起的网络请求自动添加超时控制;你想为每个异步操作统一添加日志记录,追踪其开始、成功或失败;你希望实现一个自动重试机制,当某个异步操作因网络抖动失败时能自动重试若干次;或者,你需要为所有异步操作注入统一的认证令牌或请求头。这些需求本质上都是在“装饰”一个原始的 Promise (比如 fetch 返回的 Promise )。如果装饰不当,你可能会遇到 Promise 链断裂、错误被静默吞没、状态管理混乱等难以调试的问题。因此,“不破坏承诺”是这项技术的底线和最高准则。

本文将从一名一线开发者的视角,深入拆解如何安全、有效地装饰 Promise 。我们将从理解 Promise 的核心契约开始,逐步构建起装饰器的几种经典模式,分析每种模式的适用场景与潜在风险,并分享在实际大型项目中积累的避坑经验和性能考量。无论你是想提升代码的抽象能力,还是正在为复杂的异步流程管理寻找优雅的解决方案,这篇文章都将为你提供一套可直接复用的“工具箱”和清晰的“安全操作指南”。

2. Promise的核心契约与装饰的边界

在动手装饰任何东西之前,我们必须彻底理解被装饰对象的“原厂规格”。对于 Promise ,它的核心契约远不止于“将来会有一个值”这么简单。破坏这些契约,轻则导致功能异常,重则引发难以追踪的幽灵bug。

2.1 不可变的状态机

Promise 是一个状态机,其状态一旦改变就不可逆:

  • Pending(进行中) :初始状态。
  • Fulfilled(已成功) :操作成功完成,拥有一个不可变的决议值(value)。
  • Rejected(已失败) :操作失败,拥有一个不可变的拒绝原因(reason)。

装饰的第一条铁律:绝不能改变一个已决议(settled)Promise的状态或值。 这意味着,如果你的装饰器在 Promise 已经成功或失败后,试图去修改它最终传递的值,或者强行将其从失败转为成功(反之亦然),就彻底违背了 Promise 的设计哲学,会使得依赖于该 Promise 的下游代码行为完全不可预测。

2.2 Then方法的契约

Promise.prototype.then 方法是整个 Promise 链式调用的基石。根据规范, then 方法必须返回一个新的 Promise (记作 promise2 )。这个新 Promise 的决议行为,严格取决于 then 中传入的成功回调( onFulfilled )和失败回调( onRejected )的执行结果。

装饰的第二条铁律:必须保持thenable的链式特性。 装饰后的对象,必须仍然是一个标准的、行为可预测的 Thenable 对象(即拥有 then 方法)。装饰器最常见的错误之一,就是返回了一个非 Promise 对象,或者一个行为怪异的 Promise ,导致链式调用在某一环突然断裂。

2.3 错误传播与吞没

Promise 的错误处理机制是自动的、向下传播的。如果一个 Promise 被拒绝(rejected),并且没有对应的 catch 处理,这个拒绝状态会一直沿着链条向后传递,直到被捕获。这是 Promise 相较于传统回调模式的一大优势。

装饰的第三条铁律:绝不能无意中吞没错误。 这是装饰 Promise 时最高发的“事故”。例如,在装饰器添加的 then 回调中,如果发生了同步错误且未被捕获,或者错误被不当处理,就可能导致原始的拒绝原因被掩盖,替换成一个新的、可能信息量更少的错误,甚至让错误完全消失,使得调试变得极其困难。

2.4 微任务调度

Promise 的回调( then catch finally )被调度为微任务(microtask)。这意味着它们会在当前同步任务执行完毕、下一个宏任务开始之前被执行。这个特性保证了异步操作的时序可预测性。

装饰的第四条铁律:注意装饰逻辑的执行时机。 如果你的装饰逻辑涉及同步操作,它会被立即执行;如果涉及异步操作,你需要考虑它是否会引入不必要的宏任务延迟,从而影响整个应用的响应性或与其他微任务的交互顺序。一个设计不良的装饰器可能会破坏原有的、精密的微任务时序。

理解并尊重这些边界,是我们进行任何 Promise 装饰操作的前提。接下来,我们将看到,安全的装饰模式都是建立在对这些契约的严格遵守之上的。

3. 安全的Promise装饰模式解析

掌握了核心契约,我们就可以开始探讨具体的装饰模式了。根据装饰逻辑发生的位置和方式,我们可以将其归纳为几种经典模式,每种都有其特定的适用场景和需要警惕的陷阱。

3.1 包装模式:最通用与安全的基础

包装模式是最直观、也是最安全的装饰方式。核心思想是:接收一个原始 Promise (或一个返回 Promise 的函数),返回一个新的、包装过的 Promise 。新 Promise 内部会等待原始 Promise 决议,并在决议前后执行我们的装饰逻辑。

function decorateWithLogging(promise) {
  // 立即记录开始,这是同步执行的装饰逻辑
  console.log(‘[Async Operation] Started’);
  
  // 返回一个新的Promise,它是我们装饰后的产物
  return promise
    .then(value => {
      // 原始Promise成功后的装饰逻辑
      console.log(‘[Async Operation] Succeeded:’, value);
      // 重要:将原始值原封不动地传递下去
      return value;
    })
    .catch(error => {
      // 原始Promise失败后的装饰逻辑
      console.error(‘[Async Operation] Failed:’, error);
      // 重要:重新抛出错误,保持错误传播链
      throw error;
    });
}

// 使用示例
const rawPromise = fetch(‘/api/data’);
const decoratedPromise = decorateWithLogging(rawPromise);
decoratedPromise.then(data => console.log(‘Got data’, data));

为什么这是安全的?

  1. 状态不可变性 :我们从未试图修改原始 promise 的状态。我们只是创建了一个新的 promise ,它“观察”原始 promise 并根据其结果执行额外操作。
  2. 链式完整性 decorateWithLogging 函数返回的仍然是一个标准的 Promise ,可以继续调用 then catch
  3. 错误传播 :在 catch 块中,我们使用 throw error 重新抛出了原始错误。这确保了错误原因不变,且继续向下游传播。如果这里我们 return 了一个新值,就会将失败“转化”为成功,这通常是错误的。
  4. 值传递 :在 then 块中,我们 return value ,将原始成功值透明地传递下去。

包装模式的变体:装饰函数 更常见的场景是,我们想装饰的是一个 会返回Promise的函数 ,而不是一个已经创建的Promise实例。

function withRetry(asyncFn, maxRetries = 3) {
  return function (...args) {
    return new Promise((resolve, reject) => {
      let attempts = 0;
      
      function attempt() {
        asyncFn(...args)
          .then(resolve) // 成功则直接解决外部Promise
          .catch(error => {
            attempts++;
            if (attempts <= maxRetries) {
              console.log(`Attempt ${attempts} failed, retrying...`);
              attempt(); // 重试
            } else {
              reject(error); // 耗尽重试次数,抛出最终错误
            }
          });
      }
      
      attempt();
    });
  };
}

// 使用:装饰一个fetch函数
const reliableFetch = withRetry(fetch, 2);
reliableFetch(‘/api/unstable’).then(/* ... */);

这种模式将装饰逻辑提升到了函数级别,更加灵活,是创建可复用异步工具函数的基石。

3.2 继承扩展模式:面向对象的装饰

如果你正在使用ES6类,并且你的异步操作被封装在类的方法中,继承是一种结构化的装饰方式。

class BaseService {
  async request(url) {
    const response = await fetch(url);
    return response.json();
  }
}

class LoggedService extends BaseService {
  async request(url) {
    console.time(‘request’);
    try {
      const result = await super.request(url); // 调用父类原始方法
      console.timeEnd(‘request’);
      console.log(‘Success for’, url);
      return result; // 返回原始结果
    } catch (error) {
      console.timeEnd(‘request’);
      console.error(‘Failed for’, url, error);
      throw error; // 重新抛出原始错误
    }
  }
}

优点 :结构清晰,符合面向对象设计原则,能很好地装饰整个类的方法族。 缺点 :不够灵活,无法动态装饰单个函数或已存在的Promise实例。且 super 调用是静态的,装饰逻辑与原始逻辑耦合在同一个类层次中。

3.3 高阶函数与中间件模式:管道化装饰

这是Node.js后端框架(如Express、Koa)中非常流行的模式,也适用于组织复杂的前端异步逻辑。其核心是创建一个装饰器(中间件)的管道(pipeline),每个装饰器接收一个“上下文”和 next 函数, next 函数代表执行下一个装饰器或最终的核心操作。

// 一个简单的Promise中间件实现
function createAsyncPipeline(...middlewares) {
  return function (initialContext) {
    let index = -1;
    
    function dispatch(i, context) {
      if (i <= index) return Promise.reject(new Error(‘next() called multiple times’));
      index = i;
      
      let middleware = middlewares[i];
      // 如果所有中间件执行完毕,返回一个已解决的Promise作为终点
      if (i === middlewares.length) {
        return Promise.resolve(context);
      }
      
      try {
        // 执行中间件,传入上下文和next函数(即调用下一个中间件)
        return Promise.resolve(
          middleware(context, () => dispatch(i + 1, context))
        );
      } catch (err) {
        return Promise.reject(err);
      }
    }
    
    return dispatch(0, initialContext);
  };
}

// 定义装饰器(中间件)
const logger = async (ctx, next) => {
  console.log(‘Request started:’, ctx.url);
  const start = Date.now();
  try {
    const result = await next(); // 执行后续中间件和核心逻辑
    console.log(`Request succeeded in ${Date.now() - start}ms`);
    return result;
  } catch (error) {
    console.log(`Request failed in ${Date.now() - start}ms`);
    throw error;
  }
};

const timeout = (ms) => async (ctx, next) => {
  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
  );
  // 竞速:核心操作与超时触发器
  return Promise.race([next(), timeoutPromise]);
};

// 组合使用
const pipeline = createAsyncPipeline(logger, timeout(5000));
const context = { url: ‘/api/data’ };
pipeline(context)
  .then(result => console.log(‘Final result:’, result))
  .catch(err => console.error(‘Pipeline error:’, err));

这种模式的强大之处 在于其声明式和可组合性。你可以像搭积木一样,将超时、重试、认证、日志、缓存等装饰器任意组合,应用到不同的异步流程上,而核心业务逻辑保持干净。它严格遵循了“不破坏Promise”的原则,因为每个中间件都必须妥善处理它接收到的 Promise (即 next() 的返回值)并返回一个新的 Promise

4. 核心装饰器实现与避坑指南

理论说再多,不如亲手实现几个最常用的装饰器。在这一部分,我们将深入三个最典型装饰器——超时、重试、并发控制的实现细节,并重点分析其中容易踩坑的地方。

4.1 超时装饰器:与时间的赛跑

为异步操作添加超时控制是刚性需求。一个健壮的超时装饰器需要在时间耗尽时果断拒绝,但同时也要妥善处理原始 Promise ,避免资源泄漏。

/**
 * 为Promise添加超时功能
 * @param {Promise} promise 原始Promise
 * @param {number} ms 超时时间(毫秒)
 * @param {string|Error} [timeoutError] 自定义超时错误
 * @returns {Promise} 带有超时控制的Promise
 */
function withTimeout(promise, ms, timeoutError = `Operation timed out after ${ms}ms`) {
  // 创建一个在指定时间后拒绝的Promise
  let timeoutId;
  const timeoutPromise = new Promise((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(timeoutError instanceof Error ? timeoutError : new Error(timeoutError));
    }, ms);
  });
  
  // 使用Promise.race竞速
  return Promise.race([promise, timeoutPromise])
    .finally(() => {
      // 关键清理步骤:无论谁赢,都要清除定时器,防止内存泄漏
      clearTimeout(timeoutId);
    });
}

避坑指南:

  1. 内存泄漏 :这是最隐蔽的坑。 setTimeout 会持有回调引用,即使我们的 timeoutPromise 在竞速中输了(即原始 promise 先完成),定时器仍然存在于事件循环中,直到 ms 时间后才会被触发并释放。如果不手动 clearTimeout ,在装饰大量 Promise 时会造成严重的内存泄漏。 finally 块确保了在任何情况下都会执行清理。
  2. 错误信息丢失 :超时错误应该提供清晰的上下文。最好创建一个 Error 实例,并包含超时时间等信息,方便调试。直接reject一个字符串不利于错误堆栈的捕获。
  3. 取消副作用 Promise.race 只是忽略了较慢的那个 Promise 的结果,但并没有“取消”它。如果原始 promise 关联着一个实际的操作(如网络请求),这个操作可能仍在后台进行。真正的“取消”需要原始操作支持(如 AbortController )。超时装饰器只解决“等待”层面的超时,而非“操作”层面的中止。

4.2 自动重试装饰器:提升鲁棒性

对于因网络抖动等临时性故障导致失败的操作,自动重试能显著提升成功率。重试逻辑需要考虑重试次数、退避策略和错误过滤。

/**
 * 创建自动重试装饰器
 * @param {Function} asyncFn 返回Promise的异步函数
 * @param {Object} options 配置项
 * @param {number} options.retries 最大重试次数(默认3)
 * @param {Function} options.shouldRetry 判断错误是否应重试的函数 (error, attempt) => boolean
 * @param {Function} options.delay 计算重试延迟的函数 (attempt) => ms
 */
function withRetry(asyncFn, options = {}) {
  const { retries = 3, shouldRetry = () => true, delay = (attempt) => 0 } = options;
  
  return function (...args) {
    return new Promise((resolve, reject) => {
      let attempt = 0;
      
      function execute() {
        asyncFn(...args)
          .then(resolve)
          .catch(error => {
            attempt++;
            const canRetry = attempt <= retries && shouldRetry(error, attempt);
            
            if (!canRetry) {
              // 不再重试,用最终错误拒绝
              reject(error);
              return;
            }
            
            const waitTime = delay(attempt);
            console.warn(`Attempt ${attempt} failed. Retrying in ${waitTime}ms...`, error.message);
            
            // 延迟后重试
            setTimeout(execute, waitTime);
          });
      }
      
      execute();
    });
  };
}

// 使用示例:指数退避策略
const fetchWithRetry = withRetry(fetch, {
  retries: 5,
  shouldRetry: (error) => {
    // 只对网络错误或5xx服务器错误重试
    return error.name === ‘TypeError’ || // 网络错误通常是TypeError
           (error.status >= 500 && error.status < 600);
  },
  delay: (attempt) => Math.min(1000 * Math.pow(2, attempt - 1), 30000) // 指数退避,上限30秒
});

避坑指南:

  1. 无脑重试 :并非所有错误都适合重试。像 401 Unauthorized (认证失败)或 400 Bad Request (客户端错误),重试多少次都没用,只会增加服务器负担。 shouldRetry 选项至关重要,必须根据错误类型进行过滤。
  2. 重试风暴 :如果服务器宕机,所有客户端同时立即重试,会形成“重试风暴”,阻碍服务器恢复。 指数退避 是必须的。它让每次重试的等待时间指数级增加(如1s, 2s, 4s, 8s...),并加上随机抖动(jitter),可以有效打散客户端请求。
  3. 幂等性 :确保被重试的操作是 幂等 的。即重复执行多次与执行一次的效果相同。 GET PUT DELETE 通常是幂等的,而 POST (创建)往往不是。重试非幂等操作可能导致数据重复等副作用。
  4. 副作用累积 :每次重试,装饰器内部的 console.warn 或日志记录都会执行。在高频操作中,这可能产生大量日志。生产环境中应考虑将日志级别调高或使用更智能的日志策略。

4.3 并发控制装饰器:限制资源消耗

有时我们需要限制同时执行的异步任务数量,例如避免同时发起太多网络请求拖垮浏览器或触发服务器限流。

/**
 * 创建一个并发控制器
 * @param {number} concurrency 最大并发数
 * @returns {Function} 一个函数,接收返回Promise的函数,返回受控的Promise
 */
function createConcurrencyLimiter(concurrency) {
  if (concurrency < 1) throw new Error(‘Concurrency must be at least 1’);
  
  let running = 0;
  const queue = [];
  
  function runNext() {
    // 如果并发数已满或队列为空,则停止
    if (running >= concurrency || queue.length === 0) {
      return;
    }
    
    running++;
    const { asyncFn, args, resolve, reject } = queue.shift();
    
    const taskPromise = Promise.resolve(asyncFn(...args));
    
    taskPromise
      .then((result) => {
        resolve(result);
      })
      .catch((error) => {
        reject(error);
      })
      .finally(() => {
        running--;
        // 一个任务完成,尝试执行下一个
        process.nextTick(runNext); // 在Node.js中,使用nextTick避免递归爆栈。浏览器可用setTimeout(fn, 0)
      });
    
    // 如果还有空位,继续执行下一个(管道式填充)
    if (running < concurrency) {
      process.nextTick(runNext);
    }
  }
  
  return function (asyncFn) {
    return function (...args) {
      return new Promise((resolve, reject) => {
        // 将任务加入队列
        queue.push({ asyncFn, args, resolve, reject });
        // 尝试执行
        process.nextTick(runNext);
      });
    };
  };
}

// 使用示例:限制最多3个并发请求
const limit = createConcurrencyLimiter(3);
const limitedFetch = limit(fetch);

// 同时发起10个请求,但最多只有3个同时进行
const promises = Array.from({ length: 10 }, (_, i) => 
  limitedFetch(`/api/item/${i}`).then(r => r.json())
);
Promise.all(promises).then(results => console.log(‘All done’, results));

避坑指南:

  1. 队列管理 :此实现使用了一个简单的FIFO(先进先出)队列。在复杂场景下,你可能需要优先级队列。确保队列操作是同步的,避免竞态条件。
  2. 递归调用与堆栈溢出 :在 finally 中直接调用 runNext() ,如果任务完成速度极快,可能导致同步递归调用链过长,在JavaScript中可能引发堆栈溢出。使用 process.nextTick (Node.js)或 setTimeout(fn, 0) (浏览器)将下一次执行放到下一个事件循环,可以避免这个问题。
  3. 错误处理一致性 :装饰器必须确保原始 asyncFn 的拒绝原因被正确地传递给调用者。我们在 taskPromise.catch 中直接 reject(error) ,保证了这一点。
  4. 资源释放 :这个装饰器主要管理执行许可。如果任务本身持有资源(如文件句柄、数据库连接),需要在任务函数内部确保资源最终被释放,装饰器无法代劳。

5. 高级组合与性能考量

单个装饰器已经很有用,但真正的威力在于组合。然而,随意组合装饰器可能会引入意想不到的复杂性和性能开销。

5.1 装饰器的组合顺序

装饰器的组合顺序至关重要,不同的顺序可能产生完全不同的效果。

// 假设我们有两个装饰器:超时和重试
const fetchWithTimeoutAndRetry = withRetry(withTimeout(fetch, 2000), { retries: 2 });
const fetchWithRetryAndTimeout = withTimeout(withRetry(fetch, { retries: 2 }), 2000);
  • fetchWithTimeoutAndRetry :先加超时,再加重试。这意味着 每次重试都有独立的2秒超时 。如果一次请求在1.9秒时超时,会触发重试,新的请求又有2秒时间。总耗时可能接近 2秒 * (重试次数+1)
  • fetchWithRetryAndTimeout :先加重试,再加超时。这意味着 整个重试过程(包括所有重试)总共只有2秒时间 。如果第一次请求花了1.5秒失败,第二次请求可能只有0.5秒时间,这很可能不够。

哪种顺序正确?取决于你的业务逻辑。如果你希望每次尝试都有充足但有限的时间,用前者。如果你希望整个操作(含重试)必须在总时间内完成,用后者。你必须根据语义仔细设计顺序。

5.2 避免装饰器地狱与性能损耗

虽然装饰器模式很优雅,但过度嵌套会导致“装饰器地狱”,降低代码可读性,并带来性能损耗。

// 难以阅读的装饰器地狱
const superFetch = withMetrics(
  withCache(
    withAuth(
      withRetry(
        withTimeout(fetch, 5000),
        { retries: 3 }
      ),
      getToken
    ),
    { ttl: 60000 }
  ),
  metricsCollector
);

解决方案:

  1. 使用管道/中间件模式 :如前所述,中间件模式能以更声明式、线性的方式组合行为。
  2. 创建组合函数 :编写一个工具函数来组合多个装饰器。
    function composeDecorators(...decorators) {
      return (fn) => decorators.reduceRight((wrapped, decorator) => decorator(wrapped), fn);
    }
    const enhance = composeDecorators(
      fn => withTimeout(fn, 5000),
      fn => withRetry(fn, { retries: 3 }),
      fn => withAuth(fn, getToken),
    );
    const superFetch = enhance(fetch);
    
  3. 性能考量 :每个装饰器都会引入额外的函数调用、 Promise 封装和微任务。在性能关键的循环中(例如渲染每一帧时处理大量数据),这可能会成为瓶颈。对策包括:
    • 按需装饰 :只在必要时应用装饰器。
    • 缓存装饰结果 :如果装饰器是纯函数(输出仅取决于输入),可以缓存装饰后的函数。
    • 在更底层装饰 :如果可能,在更底层、调用次数更少的地方应用装饰(例如装饰一个会调用多次的模块入口函数,而不是装饰内部每个小函数)。

5.3 调试被装饰的Promise

装饰后的 Promise 在调试时,错误堆栈可能会变得冗长且令人困惑,因为堆栈中会包含装饰器内部的调用路径。

技巧:保持错误堆栈清晰

  1. 在创建自定义错误时,确保使用 new Error() 并保留原始错误的堆栈信息。
    .catch(error => {
      const decoratedError = new Error(`Operation failed: ${error.message}`);
      // 将原始堆栈附加到新错误上,方便追踪
      decoratedError.stack = `DecoratedError: ${decoratedError.message}\nCaused by: ${error.stack}`;
      throw decoratedError;
    });
    
  2. 在Node.js中,可以使用 Error.captureStackTrace 来生成更清晰的堆栈。
  3. 在开发环境中,可以考虑给装饰后的 Promise 添加一个自定义的 Symbol 属性,用于标识其被哪些装饰器处理过,辅助调试。

6. 实战:构建一个健壮的API客户端

让我们综合运用以上知识,构建一个用于生产环境的、功能丰富的API客户端装饰器。它将集成超时、重试、认证、请求/响应拦截、并发控制等常用功能。

// 核心装饰器组合工具
function compose(...decorators) {
  return (fn) => decorators.reduceRight((acc, decorator) => decorator(acc), fn);
}

// 1. 基础请求函数(使用fetch)
async function coreRequest(url, options = {}) {
  const response = await fetch(url, options);
  if (!response.ok) {
    const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
    error.status = response.status;
    error.response = response;
    throw error;
  }
  return response.json(); // 假设默认返回JSON
}

// 2. 各个装饰器工厂
const withBaseUrl = (baseUrl) => (fn) => (url, ...args) => 
  fn(new URL(url, baseUrl).toString(), ...args);

const withTimeout = (ms) => (fn) => async (...args) => {
  const timeoutPromise = new Promise((_, reject) => 
    setTimeout(() => reject(new Error(`Request timeout after ${ms}ms`)), ms)
  );
  const requestPromise = fn(...args);
  // 使用Promise.race,并在finally中清理
  let timeoutId;
  const timeoutWrapper = new Promise((_, reject) => {
    timeoutId = setTimeout(() => reject(new Error(`Request timeout after ${ms}ms`)), ms);
  });
  return Promise.race([requestPromise, timeoutWrapper]).finally(() => clearTimeout(timeoutId));
};

const withRetry = ({ maxRetries = 3, baseDelay = 100, shouldRetry = (e) => e.status >= 500 }) => (fn) => {
  return async function retryWrapped(...args) {
    let lastError;
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await fn(...args);
      } catch (error) {
        lastError = error;
        if (attempt === maxRetries || !shouldRetry(error)) break;
        
        const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 100; // 指数退避+抖动
        console.warn(`Attempt ${attempt + 1} failed, retrying in ${Math.round(delay)}ms`, error.message);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
    throw lastError; // 抛出最后一次的错误
  };
};

const withAuth = (getToken) => (fn) => async (...args) => {
  const token = await getToken(); // getToken可以是同步或异步的
  const [url, options = {}] = args;
  const headers = new Headers(options.headers);
  headers.set(‘Authorization’, `Bearer ${token}`);
  return fn(url, { ...options, headers });
};

// 3. 创建增强的API客户端
function createApiClient(config) {
  const {
    baseUrl,
    timeout = 10000,
    retryOptions = {},
    getToken,
    // 可以继续添加缓存、日志等配置
  } = config;
  
  // 按顺序组合装饰器:认证 -> 重试 -> 超时 -> 基础URL -> 核心请求
  // 注意:执行顺序是从右到左(reduceRight),所以列表后面的先应用
  const decorators = [];
  if (baseUrl) decorators.push(withBaseUrl(baseUrl));
  decorators.push(withTimeout(timeout));
  decorators.push(withRetry(retryOptions));
  if (getToken) decorators.push(withAuth(getToken));
  
  const enhancedRequest = compose(...decorators)(coreRequest);
  
  // 返回一个具备常用方法的客户端对象
  return {
    get: (url, options) => enhancedRequest(url, { ...options, method: ‘GET’ }),
    post: (url, data, options) => enhancedRequest(url, { 
      ...options, 
      method: ‘POST’, 
      body: JSON.stringify(data),
      headers: { ‘Content-Type’: ‘application/json’, ...options?.headers }
    }),
    // 可以继续添加put, delete, patch等方法
    request: enhancedRequest, // 原始增强函数
  };
}

// 4. 使用示例
const api = createApiClient({
  baseUrl: ‘https://api.example.com’,
  timeout: 8000,
  retryOptions: { maxRetries: 2, shouldRetry: (e) => e.status === 429 || e.status >= 500 }, // 对429(限流)和5xx重试
  getToken: async () => localStorage.getItem(‘auth_token’),
});

// 发起一个健壮的GET请求
api.get(‘/users/me’)
  .then(user => console.log(‘User:’, user))
  .catch(error => console.error(‘Request failed:’, error.message, error.status));

这个实战案例展示了如何将多个独立的、职责单一的装饰器组合成一个强大、可配置、易维护的API客户端。每个装饰器都严格遵守了“不破坏Promise”的原则,通过清晰的错误传播和资源管理,确保了整个异步流程的健壮性。你可以根据项目需要,轻松地添加或移除装饰器(如请求缓存、响应数据转换、全局错误处理等),而不会影响核心请求逻辑。

更多推荐