通过共享 Promise 解决前端重复请求-最终篇

372 阅读28分钟

进阶篇代码使用的缓存淘汰策略是 FIFO (First-In, First-Out),因为它在缓存满时总是删除 this.pool.keys().next().value,即最早插入 Map 的条目。

我将其优化为 LRU (Least Recently Used - 最近最少使用) 策略。LRU 的核心思想是:当缓存满时,优先淘汰最长时间未被访问(读取或写入)的条目。

优化方法:利用 Map 的特性模拟 LRU

JavaScript 的 Map 会记住元素的插入顺序。我们可以利用这一点,在每次访问(getset)缓存时,将被访问的条目重新插入到 Map 中,这样它就会被移动到迭代顺序的末尾,代表“最近使用”。当需要淘汰时,我们仍然删除迭代顺序的第一个元素 (keys().next().value),此时它代表的就是“最久未使用”的条目。

主要修改点解释:

  1. 缓存命中 (request 方法内):

    • 当找到有效缓存 (this.pool.has(key) 且未过期) 时:
    • this.pool.delete(key) 删除该条目。
    • this.pool.set(key, entry) 将其重新添加。
    • 这利用了 Map 按插入顺序迭代的特性,将刚访问的条目移动到了内部顺序的末尾,标记为“最近使用”。
  2. 缓存淘汰 (request 方法内):

    • 检查 this.pool.size >= this.maxSize 的逻辑不变。
    • 获取 const lruKey = this.pool.keys().next().value; 也不变。
    • 但此时,由于命中时会将元素移到末尾,所以 keys().next().value 获取到的实际上是最久未被访问(读取或添加/更新)的键,即 LRU 键。
    • 删除 lruKey 对应的条目、中止其 Promise、删除其 AbortController。
  3. 添加新条目 (request 方法内):

    • 执行 this.pool.set(key, ...) 时,新条目会被自动添加到 Map 的末尾,自然成为“最新”的条目。
  4. 处理过期 (request 方法内):

    • 当发现缓存存在但已过期时,明确地从 poolabortControllers 中删除该条目,避免它作为“最久未使用”的项被错误地保留在 Map 的开头。
  5. 重试逻辑 (handleRetry):

    • 调整了 handleRetry 的调用方式,传递递减后的重试次数配置。
    • handleRetry 内部重新发起 this.request 时,如果请求成功,这个 request 调用会像正常添加新条目一样,将成功的 Promise 放入 pool,并更新其 LRU 位置。
    • 如果重试的 this.request 命中了缓存(理论上不太可能,因为是刚失败的请求,但逻辑上要考虑),也会更新 LRU 位置。
    • 添加了在 handleRetry 内部更新 pool 中 Promise 的逻辑,确保缓存持有的是最新的重试 Promise。
  6. 清理和中止:

    • 确保在 abort, cleanup, destroy, 以及淘汰逻辑中,都同时清理 this.poolthis.abortControllers 中的对应条目。
  7. 日志: 添加了一些 console.log 来帮助追踪缓存命中、未命中、过期、淘汰和重试等行为,方便调试。上线时可以移除或使用更完善的日志库。

  8. 表单忽略: 因为表单可能存在文件,所以将整个 data(即 FormData 对象)尝试序列化并包含在缓存键 key 中是不合适的。添加了表单上传请求判断(通过 instanceof FormData 或检查 Content-Type),然后让这些请求完全绕过缓存机制(读和写)。

修改后的代码:

// 这里我引入的是axios已经创建的实例,这样可以和现有项目完美结合
import { axiosIns } from "./httpService.js"; // 确保你的 httpService.js 路径正确
import { stringify } from "qs"; // 用于序列化参数和数据
import CryptoJS from "crypto-js"; // 用于可选的键加密

class AdvancedRequestPool {
  constructor(options = {}) {
    // 请求池 (LRU Map: key -> { promise, expire, config })
    // 用于存储可缓存请求的 Promise、过期时间和配置
    this.pool = new Map();
    // 默认缓存时间(毫秒),如果请求配置中未指定 cacheTTL,则使用此值
    this.defaultTTL = options.defaultTTL || 1000;
    // 请求池的最大大小 (LRU 限制),超出时会淘汰最久未使用的条目
    this.maxSize = options.maxSize || 100;
    // 可选的加密密钥,用于加密生成的缓存键
    this.encryptKey = options.encryptKey || null;

    // 存储 AbortController 引用 (key -> AbortController)
    // key 可能是缓存键,也可能是非缓存请求的临时键,用于中止请求
    this.abortControllers = new Map();

    // 重试规则配置,或使用默认规则
    this.retryRules =
      options.retryRules || AdvancedRequestPool.defaultRetryRules;

    // --- 定时清理相关 ---
    this._cleanupTimerId = null; // 存储 setTimeout 的 ID,用于取消定时器
    this._isDestroyed = false; // 标志实例是否已被销毁
    // 是否启用定时清理功能
    this.enableRegularCleaning = options.enableRegularCleaning || false;
    // 定时清理的间隔时间(毫秒)
    this.cleanupIntervalMs = options.cleanupIntervalMs || 60000;

    // 如果启用了定时清理,则开始调度
    if (this.enableRegularCleaning) {
      this._scheduleNextCleanup();
    }
  }

  // 默认重试规则
  static defaultRetryRules = {
    networkErrors: true, // 网络错误(如无法连接服务器)是否重试
    httpStatus: [500, 502, 503, 504, 429, 408], // 哪些 HTTP 状态码触发重试
    customCheck: null, // 自定义检查函数,接收 error 对象,返回 true 表示可重试
  };

  // --- 辅助方法 ---

  /**
   * 判断错误是否是中止错误 (AbortError)
   * @param {Error} error - 错误对象
   * @returns {boolean}
   */
  isAbortError(error) {
    // 检查标准的 AbortError 名称
    // 检查 Axios 的取消错误(如果使用了 Axios 的 CancelToken)
    // 检查 Nginx 特定的客户端关闭状态码 499
    // 检查错误消息中是否包含 "aborted" 或 "cancel" (不区分大小写)
    return (
      error.name === "AbortError" ||
      axiosIns.isCancel?.(error) ||
      (error.response && error.response.status === 499) ||
      error.message?.toLowerCase().includes("aborted") ||
      error.message?.toLowerCase().includes("cancel")
    );
  }

  /**
   * 判断错误是否是可重试的错误
   * @param {Error} error - 错误对象
   * @returns {boolean}
   */
  isRetryableError(error) {
    // 如果是中止错误,则不可重试
    if (this.isAbortError(error)) return false;
    // 如果定义了自定义检查逻辑,则使用它
    if (this.retryRules.customCheck) {
      try {
        return this.retryRules.customCheck(error);
      } catch (e) {
        console.error("自定义重试检查函数出错:", e);
        return false; // 检查函数出错视为不可重试
      }
    }
    // 如果启用了网络错误重试且错误没有 response (通常表示网络层问题)
    if (this.retryRules.networkErrors && !error.response) return true;
    // 如果有 response,检查状态码是否在可重试列表中
    if (error.response) {
      const status = error.response.status;
      return this.retryRules.httpStatus.includes(status);
    }
    // 其他情况默认为不可重试
    return false;
  }

  // --- 核心方法 ---

  /**
   * 为可缓存的请求生成唯一的缓存键。
   * 基于请求方法、URL、排序后的参数和排序/序列化后的数据。
   * **明确排除了 FormData 类型的数据参与生成缓存键。**
   * @param {object} config - Axios 请求配置对象。
   * @returns {string|null} - 如果可生成缓存键则返回字符串键,否则返回 null (例如对于 FormData)。
   */
  generateCacheKey(config) {
    // 如果请求数据是 FormData,则不为其生成缓存键
    if (config.data instanceof FormData) {
      return null; // 返回 null 表示不应为此请求生成标准缓存键
    }

    const { method, url, params, data } = config;
    // 对参数进行排序并序列化,确保顺序一致性
    const sortedParams = params
      ? stringify(params, { sort: (a, b) => a.localeCompare(b) })
      : "";

    let sortedData = "";
    // 处理请求体数据 (data)
    if (typeof data === "object" && data !== null) {
      // 确保普通对象序列化的一致性
      try {
        // 使用 qs 序列化并排序,与 params 保持一致
        sortedData = stringify(data, { sort: (a, b) => a.localeCompare(b) });
        // 备选方案: 使用 JSON.stringify 并对键排序(更常见,但对复杂对象可能不够健壮?)
        // sortedData = JSON.stringify(data, Object.keys(data).sort());
      } catch (e) {
        console.warn("无法序列化 data 以生成缓存键,使用占位符:", e);
        sortedData = "[Unstringifiable Object]"; // 使用占位符表示无法序列化
      }
    } else if (data !== undefined && data !== null) {
      // 其他类型的数据直接转换为字符串
      sortedData = String(data);
    }

    // 拼接成原始键字符串
    const rawKey = `${method}-${url}-${sortedParams}-${sortedData}`;

    // 如果配置了加密密钥,则使用 HMAC-SHA256 加密原始键
    return this.encryptKey
      ? CryptoJS.HmacSHA256(rawKey, this.encryptKey).toString(CryptoJS.enc.Hex) // 使用 Hex 编码输出
      : rawKey; // 否则直接使用原始键
  }

  /**
   * 发起一个请求,可能会使用或存储在缓存池中。
   * 对于文件上传请求 (FormData) 会跳过缓存机制。
   * @param {object} config - Axios 请求配置对象。除了标准的 Axios 选项外,还支持以下自定义选项:
   *   - cacheTTL {number}: 此请求特定的缓存生存时间(毫秒)。
   *   - keepAlive {boolean}: 如果为 true,则阻止在 finally 块中自动清理(但仍受 LRU 和定时清理的影响)。
   *   - forceRefresh {boolean}: 如果为 true,则强制绕过缓存读取。
   *   - retryCount {number}: 允许的重试次数。
   *   - retryDelay {number}: 重试之间的延迟时间(毫秒)。
   *   - autoRefresh {boolean}: 是否启用自动后台刷新。
   *   - refreshInterval {number}: 自动刷新的间隔时间(毫秒)。
   * @returns {Promise} - 返回一个 Promise,该 Promise 会 resolve Axios 的响应或 reject 错误。
   *                    这个 Promise 额外附加了一个 `abort()` 方法用于中止请求。
   */
  async request(config) {
    // 获取此请求的有效 TTL
    const ttl = config.cacheTTL ?? this.defaultTTL;
    // 判断是否是文件上传请求
    const isFileUpload =
      config.data instanceof FormData ||
      (config.headers &&
        config.headers["Content-Type"]?.includes("multipart/form-data"));

    // 判断此请求是否应该被缓存 (TTL > 0, 不是文件上传, 没有强制刷新)
    const isCacheable = ttl > 0 && !isFileUpload && !config.forceRefresh;

    let cacheKey = null; // 用于 this.pool 缓存的键
    let abortKey = null; // 用于 this.abortControllers 的键 (可能是 cacheKey 或临时键)

    // --- 缓存读取 / 键生成 ---
    if (isCacheable) {
      // 仅在可缓存时生成缓存键
      cacheKey = this.generateCacheKey(config);
      abortKey = cacheKey; // 如果可缓存,abortKey 和 cacheKey 相同
      const now = Date.now();

      // 检查缓存池中是否存在此键
      if (this.pool.has(cacheKey)) {
        const entry = this.pool.get(cacheKey);
        // 检查缓存是否未过期
        if (entry.expire > now) {
          // 缓存命中且有效: 更新 LRU 位置并返回缓存的 Promise
          this.pool.delete(cacheKey); // 先删除
          this.pool.set(cacheKey, entry); // 再添加,移到末尾 (最近使用)
          console.log(
            `[请求池] 缓存命中: ${String(cacheKey).substring(0, 50)}...`,
          );
          return entry.promise; // 直接返回缓存中的 Promise
        } else {
          // 缓存命中但已过期: 从缓存池移除,并尝试中止旧请求
          console.log(
            `[请求池] 缓存已过期: ${String(cacheKey).substring(0, 50)}...`,
          );
          entry.promise.abort?.(); // 尝试中止与过期条目关联的 Promise
          this.pool.delete(cacheKey); // 从缓存池删除
          this.abortControllers.delete(cacheKey); // 清理对应的 AbortController
        }
      } else {
        // 缓存未命中
        console.log(
          `[请求池] 缓存未命中: ${String(cacheKey).substring(0, 50)}...`,
        );
      }

      // --- LRU 缓存淘汰逻辑 (仅对可缓存请求) ---
      // 在添加新条目前检查缓存池大小是否超出限制
      if (this.pool.size >= this.maxSize) {
        // Map 的 keys().next().value 返回的是最早插入的键
        // 由于我们在命中时会重新插入,所以最早插入的键就是最久未使用的键 (LRU)
        const lruKey = this.pool.keys().next().value;
        console.log(
          `[请求池] 缓存已满, 淘汰 LRU 键: ${String(lruKey).substring(0, 50)}...`,
        );
        const lruEntry = this.pool.get(lruKey);
        lruEntry?.promise.abort?.(); // 尝试中止被淘汰的请求
        this.pool.delete(lruKey); // 从缓存池淘汰
        this.abortControllers.delete(lruKey); // 清理其对应的 AbortController
      }
    } else {
      // --- 不可缓存的请求 (文件上传, TTL <= 0, 或强制刷新) ---
      console.log("[请求池] 请求不可缓存 (FormData, TTL<=0, 或 forceRefresh).");
      // 为 AbortController 映射生成一个临时的唯一键
      // 这使得非缓存请求也能通过 promise.abort() 被中止
      abortKey = `non-cacheable-${Date.now()}-${Math.random()}`;
    }

    // --- AbortController 设置 ---
    // 总是为每个新请求(无论是否缓存)创建一个 AbortController
    // 这样可以确保 promise.abort() 始终有效
    const controller = new AbortController();
    this.abortControllers.set(abortKey, controller); // 使用 abortKey 存储 Controller

    // --- 准备最终的 Axios 配置 ---
    const finalConfig = {
      ...config, // 包含原始配置
      signal: controller.signal, // 附加 AbortSignal
    };

    // --- 执行实际请求 ---
    const promise = axiosIns(finalConfig)
      .then((response) => {
        // 请求成功后的处理
        // 如果是可缓存请求且配置了自动刷新,则安排刷新
        if (isCacheable && config.autoRefresh && cacheKey) {
          // 确保 cacheKey 存在
          this.scheduleRefresh(cacheKey, config);
        }
        return response; // 用响应对象 resolve Promise
      })
      .catch((error) => {
        // 请求失败后的处理:重试逻辑
        const currentRetryCount = config.retryCount ?? 0; // 使用原始配置获取重试次数
        // 如果还有重试次数且错误是可重试类型
        if (currentRetryCount > 0 && this.isRetryableError(error)) {
          // 准备下一次重试的配置(重试次数减 1)
          const configForNextRetry = {
            ...config, // 保留原始配置的其他部分
            retryCount: currentRetryCount - 1, // 重试次数递减
          };
          // 调用 handleRetry 处理重试,传递 abortKey, 新配置, 错误, 和原始请求是否可缓存的状态
          return this.handleRetry(
            abortKey,
            configForNextRetry,
            error,
            isCacheable,
          );
        }
        // 如果没有重试次数或错误不可重试,则抛出错误
        // 避免将用户主动中止的请求记录为永久失败
        if (!this.isAbortError(error)) {
          console.error(
            `[请求池] 请求永久失败 (key: ${String(abortKey).substring(0, 50)}...)`,
            error,
          );
        }
        throw error; // 向上抛出错误
      })
      .finally(() => {
        // --- 资源清理 ---
        // 这个 finally 块无论请求成功或失败都会执行

        if (isCacheable && cacheKey) {
          // 确保 cacheKey 存在
          // --- 可缓存请求的清理逻辑 ---
          const entry = this.pool.get(cacheKey);
          // 清理条件: 条目存在 且 (不是 keepAlive 并且 有效 TTL <= 0)
          // 正常情况下,TTL > 0 的条目会由定时清理或 LRU 处理
          // 这个逻辑主要处理边缘情况,或 TTL <= 0 但被错误缓存的情况
          const effectiveTTL = entry?.config?.cacheTTL ?? this.defaultTTL;
          if (entry && !entry.config?.keepAlive && !(effectiveTTL > 0)) {
            console.log(
              `[请求池] 立即清理非 keepAlive/无有效 TTL 的缓存条目: ${String(cacheKey).substring(0, 50)}...`,
            );
            this.pool.delete(cacheKey); // 从缓存池删除
            this.abortControllers.delete(cacheKey); // 同时删除 AbortController
          } else if (
            !this.pool.has(cacheKey) &&
            this.abortControllers.has(cacheKey)
          ) {
            // 处理孤立的 AbortController:缓存池中没有条目了,但控制器映射中还有
            // (例如:缓存被外部清除,或在 finally 执行前被中止)
            console.log(
              `[请求池] 清理可能孤立的缓存键 AbortController: ${String(cacheKey).substring(0, 50)}...`,
            );
            this.abortControllers.delete(cacheKey);
          }
          // keepAlive 或 TTL > 0 的条目留给定时清理或 LRU 处理
        } else {
          // --- 非缓存请求的清理逻辑 ---
          // 因为它们不在缓存池 (this.pool) 中,请求完成后只需清理对应的 AbortController
          // console.log(`[请求池] 清理非缓存请求的 AbortController: ${abortKey}`);
          this.abortControllers.delete(abortKey);
        }
      });

    // --- 为返回的 Promise 附加 abort() 方法 ---
    promise.abort = () => {
      // 检查对应的 AbortController 是否还存在(可能已被 finally 清理)
      if (this.abortControllers.has(abortKey)) {
        console.log(
          `[请求池] 通过 promise.abort() 中止请求: ${String(abortKey).substring(0, 50)}...`,
        );
        const abortControllerInstance = this.abortControllers.get(abortKey);
        abortControllerInstance.abort(); // 触发中止信号

        // 在显式中止时立即清理资源
        if (isCacheable && cacheKey) {
          this.pool.delete(cacheKey); // 如果是可缓存的,从缓存池移除
        }
        this.abortControllers.delete(abortKey); // 总是移除 AbortController
      } else {
        // 如果 AbortController 已不存在,说明请求已完成或已被清理
        console.warn(
          `[请求池] 尝试中止一个已完成或已清理的请求: ${String(abortKey).substring(0, 50)}...`,
        );
      }
    };

    // --- 缓存写入 (仅当请求是可缓存的时) ---
    if (isCacheable && cacheKey) {
      this.pool.set(cacheKey, {
        promise, // 存储请求的 Promise
        expire: Date.now() + ttl, // 计算过期时间戳
        config, // 存储原始配置,可能用于刷新或重试
      });
      // Map 会自动将新条目添加到末尾,符合 LRU“最新使用”的特性
    }

    return promise; // 返回带有 abort() 方法的 Promise
  }

  // --- 其他方法 ---

  /**
   * 调度下一次定时清理任务
   * @private
   */
  _scheduleNextCleanup() {
    // 如果实例已销毁或禁用了定时清理,则直接返回
    if (this._isDestroyed || !this.enableRegularCleaning) {
      return;
    }
    // 清除可能存在的旧定时器,防止重复调度
    if (this._cleanupTimerId) {
      clearTimeout(this._cleanupTimerId);
    }
    // 设置新的定时器
    this._cleanupTimerId = setTimeout(() => {
      try {
        // 执行清理操作
        this.cleanup();
      } finally {
        // 确保即使本次清理出错,也能调度下一次清理(如果实例未销毁)
        if (!this._isDestroyed) {
          this._scheduleNextCleanup();
        }
      }
    }, this.cleanupIntervalMs); // 使用配置的间隔时间
  }

  /**
   * 执行清理操作,移除缓存池中已过期的条目
   */
  cleanup() {
    // 如果实例已销毁,则不执行清理
    if (this._isDestroyed) return;
    const now = Date.now();
    let cleanedCount = 0; // 用于记录本次清理的数量
    // 遍历缓存池 (this.pool)
    this.pool.forEach((entry, key) => {
      // 如果条目已过期
      if (entry.expire < now) {
        console.log(
          `[请求池] 定时清理: 过期 ${String(key).substring(0, 50)}...`,
        );
        entry.promise.abort?.(); // 尝试中止与过期条目关联的 Promise
        this.pool.delete(key); // 从缓存池删除
        this.abortControllers.delete(key); // 删除对应的 AbortController
        cleanedCount++;
      }
    });
    // 如果本次有清理发生,则打印日志
    if (cleanedCount > 0) {
      console.log(`[请求池] 定时清理移除了 ${cleanedCount} 个过期条目。`);
    }
  }

  /**
   * 安排自动刷新任务
   * @param {string} cacheKey - 要刷新的缓存键
   * @param {object} config - 原始请求配置
   */
  scheduleRefresh(cacheKey, config) {
    // 在安排定时器之前,检查缓存条目是否仍然存在
    if (!this.pool.has(cacheKey)) {
      console.log(
        `[请求池] 刷新已取消,键已不在缓存中: ${String(cacheKey).substring(0, 50)}...`,
      );
      return;
    }

    // 获取刷新间隔,或使用默认值
    const refreshInterval = config.refreshInterval || 30000; // 默认 30 秒
    console.log(
      `[请求池] 安排键的刷新任务: ${String(cacheKey).substring(0, 50)}... 在 ${refreshInterval}ms 后`,
    );

    // 设置定时器
    setTimeout(() => {
      // 在实际执行刷新前再次检查(可能在等待期间被删除或实例被销毁)
      if (this.pool.has(cacheKey) && !this._isDestroyed) {
        console.log(
          `[请求池] 执行计划的刷新任务: ${String(cacheKey).substring(0, 50)}...`,
        );
        // 重新发起请求。request 方法会处理缓存更新和 LRU 逻辑。
        // 传递原始配置(如果需要重试,request 内部会处理 retryCount)
        // 使用 forceRefresh: true 确保绕过缓存读取,获取最新数据
        this.request({ ...config, forceRefresh: true });
      } else {
        // 如果键无效或池已销毁,则跳过刷新
        console.log(
          `[请求池] 刷新执行被跳过,键无效或池已销毁: ${String(cacheKey).substring(0, 50)}...`,
        );
      }
    }, refreshInterval);
  }

  /**
   * 处理重试逻辑
   * @param {string} abortKey - 用于 AbortController 的键(可能是缓存键或临时键)
   * @param {object} configWithDecrementedRetry - 包含递减后重试次数的请求配置
   * @param {Error} error - 导致重试的错误对象
   * @param {boolean} isOriginalRequestCacheable - 原始请求是否是可缓存的
   * @returns {Promise} - 返回一个新的 Promise,代表重试尝试的结果
   */
  async handleRetry(
    abortKey,
    configWithDecrementedRetry,
    error,
    isOriginalRequestCacheable,
  ) {
    // 获取重试延迟时间,或使用默认值
    const retryDelay = configWithDecrementedRetry.retryDelay || 1000; // 默认 1 秒延迟
    console.log(
      `[请求池] 准备重试请求 (${String(abortKey).substring(0, 50)}...). 剩余重试次数: ${configWithDecrementedRetry.retryCount}. 延迟: ${retryDelay}ms`,
    );

    // 注意:之前的失败尝试的 AbortController 应该由其自身的 finally 块清理。
    // 新的 this.request 调用会创建并管理新的 AbortController。

    // 返回一个新的 Promise 来包装延迟和重试逻辑
    return new Promise((resolve, reject) => {
      // 设置延迟执行
      setTimeout(async () => {
        // 在延迟后检查实例是否已被销毁
        if (this._isDestroyed) {
          console.warn("[请求池] 重试已取消,实例已被销毁。");
          return reject(new Error("请求池在重试延迟期间被销毁。")); // 如果在延迟期间被销毁,则 reject
        }
        try {
          // 使用递减了重试次数的配置重新发起请求
          // request 方法会再次处理缓存逻辑(如果适用)和 AbortController 的创建
          const retryPromise = this.request(configWithDecrementedRetry);

          // 不需要在这里手动更新缓存池。
          // 如果重试成功且请求是可缓存的,`request` 方法内部会负责将新的 Promise 写入缓存池。

          resolve(await retryPromise); // 用重试尝试的结果 resolve 外层 Promise
        } catch (retryError) {
          // 如果重试尝试仍然失败
          if (!this.isAbortError(retryError)) {
            // 避免记录中止错误
            console.error(
              `[请求池] 重试失败 (key: ${String(abortKey).substring(0, 50)}...)`,
              retryError,
            );
          }
          reject(retryError); // 在所有重试次数用尽后,用最终的错误 reject 外层 Promise
        }
      }, retryDelay);
    });
  }

  /**
   * (有限功能) 尝试根据原始请求配置中止一个请求。
   * 注意:此方法可能无法可靠地中止非缓存请求(如文件上传),
   * 因为它们的内部 abortKey 是临时的,无法仅从 config 推导出来。
   * 对于需要可靠中止的场景,应使用 `request()` 返回的 Promise 上附加的 `abort()` 方法。
   * @param {object} config - 用于发起请求的原始 Axios 配置对象。
   */
  abortRequest(config) {
    // 尝试根据配置生成缓存键
    const cacheKey = this.generateCacheKey(config);

    // 仅当能生成缓存键且该键存在于 abortControllers 映射中时,此方法才有效
    if (cacheKey && this.abortControllers.has(cacheKey)) {
      console.log(
        `[请求池] 通过 abortRequest() 中止缓存请求: ${String(cacheKey).substring(0, 50)}...`,
      );
      const controller = this.abortControllers.get(cacheKey);
      controller.abort(); // 触发中止
      this.pool.delete(cacheKey); // 从缓存池清理
      this.abortControllers.delete(cacheKey); // 从控制器映射清理
    } else {
      // 无法找到或中止的原因可能是:
      // 1. 这是一个非缓存请求(如文件上传)。
      // 2. 请求已经完成或已被中止。
      // 3. 提供的 config 无法生成与正在进行的请求匹配的键。
      console.warn(
        "[请求池] abortRequest() 无法找到给定配置对应的活动控制器。这可能是一个非缓存请求(请使用 promise.abort()),或者请求已完成/中止。",
      );
    }
  }

  /**
   * 销毁请求池实例,清理所有资源和定时器,中止所有进行中的请求。
   */
  destroy() {
    this._isDestroyed = true; // 设置销毁标志,阻止新的调度和操作
    // 清除定时清理的定时器
    if (this._cleanupTimerId) {
      clearTimeout(this._cleanupTimerId);
      this._cleanupTimerId = null;
    }

    // 遍历所有活动的 AbortController 并中止它们
    this.abortControllers.forEach((controller, key) => {
      console.log(
        `[请求池] 销毁中: 中止请求 ${String(key).substring(0, 50)}...`,
      );
      controller.abort();
    });

    // 清空缓存池和控制器映射
    this.pool.clear();
    this.abortControllers.clear();
    console.log("[请求池] 实例已销毁。");
  }
}

// --- 实例化和导出 ---

// 创建一个默认的请求池实例,可以在这里进行全局配置
const httpPool = new AdvancedRequestPool({
  maxSize: 50, // 示例:最大缓存条目数 50
  defaultTTL: 1000, // 示例:默认缓存 1秒钟
  enableRegularCleaning: true, // 启用定时清理
  cleanupIntervalMs: 60 * 1000, // 示例:每分钟清理一次过期缓存
  // encryptKey: "你的密钥", // 可选:如果需要加密缓存键,在此提供密钥
  // retryRules: { ... } // 可选:自定义重试规则
});

// 导出类本身,允许使用者在需要时创建自己的实例
export default AdvancedRequestPool;
// 导出默认的共享实例,方便直接使用
export { httpPool };


现在,AdvancedRequestPool 的缓存淘汰策略就从简单的 FIFO 变成了更常用的 LRU。这通常能提供更好的缓存命中率,因为它会保留那些最近被频繁访问的数据。

用法示例

一、 基本用法:使用默认实例 httpPool

最常见的方式是直接导入并使用预先配置好的 httpPool 实例。

import { httpPool } from './AdvancedRequestPool.js'; // 确保路径正确

// --- 示例 1: 发起一个简单的 GET 请求 (使用默认配置) ---
async function fetchData() {
  try {
    const config = {
      method: 'get',
      url: '/api/users/1',
      params: { includeDetails: true } // 请求参数
    };
    // 调用 httpPool.request,它返回一个 Promise
    const response = await httpPool.request(config);
    console.log('用户数据:', response.data);
    // 如果请求成功且 ttl > 0,结果会被缓存(默认5分钟)
  } catch (error) {
    if (httpPool.isAbortError(error)) {
      console.log('请求被中止:', error.message);
    } else {
      console.error('请求失败:', error.message);
      // 这里可以根据 error 类型做进一步处理
    }
  }
}

fetchData();

// --- 示例 2: 发起 POST 请求,并启用缓存和重试 ---
async function createUser() {
  let createUserPromise; // 用于后续中止
  try {
    const config = {
      method: 'post',
      url: '/api/users',
      data: { name: '张三', email: 'zhangsan@example.com' }, // 请求体数据
      // --- 自定义 Pool 相关配置 ---
      cacheTTL: 10 * 60 * 1000, // 缓存 10 分钟 (覆盖默认的 5 分钟)
      retryCount: 3,            // 失败时最多重试 3 次
      retryDelay: 2000,         // 每次重试间隔 2 秒
    };
    createUserPromise = httpPool.request(config); // 获取 Promise
    const response = await createUserPromise;
    console.log('用户创建成功:', response.data);
  } catch (error) {
    console.error('创建用户最终失败:', error.message);
  }

  // --- 示例 3: 中止请求 (如果需要) ---
  // 假设某个操作需要中止上面的创建用户请求
  // if (createUserPromise && typeof createUserPromise.abort === 'function') {
  //   console.log('尝试中止创建用户请求...');
  //   createUserPromise.abort();
  // }
}

createUser();

// --- 示例 4: 使用 then/catch 链式调用 ---
httpPool.request({ url: '/api/config' })
  .then(response => {
    console.log('配置信息:', response.data);
  })
  .catch(error => {
    console.error('获取配置失败:', error);
  });

// --- 示例 5: 使用全局 abortRequest 中止 (需要原始 config) ---
const specificConfig = { method: 'get', url: '/api/long-running-task' };
httpPool.request(specificConfig).catch(err => console.error("任务失败", err));
// ... 在某个时候 ...
// httpPool.abortRequest(specificConfig); // 会生成相同的 key 来中止请求

二、 httpPool.request(config) 方法的 config 对象参数详解

这个 config 对象接收标准的 Axios 配置参数,并额外添加了一些用于控制 AdvancedRequestPool 功能的参数。

  • 标准 Axios 参数:

    • url: 请求的 URL (必需)。
    • method: 请求方法 (如 'get', 'post', 'put', 'delete' 等,默认为 'get')。
    • params: URL 查询参数 (对象或 URLSearchParams)。
    • data: 请求体数据 (用于 'post', 'put', 'patch')。
    • headers: 自定义请求头 (对象)。
    • timeout: 请求超时时间 (毫秒)。
    • responseType: 期望的响应类型 ('json', 'blob', 'text' 等)。
    • baseURL: 会自动添加到 url 前面 (如果你的 axiosIns 配置了的话)。
    • ... 其他 Axios 支持的参数 (请参考 Axios 文档)。
    • 注意: 你不需要手动传递 signal 参数,AdvancedRequestPool 会自动处理 AbortController
  • AdvancedRequestPool 自定义参数:

    • cacheTTL (Number, 可选):
      • 作用: 单独为本次请求设置缓存的有效时间(Time To Live),单位为毫秒
      • 默认值: 如果不提供,则使用 AdvancedRequestPool 实例的 defaultTTL
      • 行为: 如果 cacheTTL (或 defaultTTL) > 0,请求成功的结果会被缓存。如果 <= 0,则不缓存。
      • 示例: cacheTTL: 60000 (缓存1分钟)。
    • retryCount (Number, 可选):
      • 作用: 当请求失败且满足重试条件 (isRetryableError 返回 true) 时,允许自动重试的最大次数。
      • 默认值: 0 (即默认不重试)。
      • 行为: 每次重试会消耗一次计数。
      • 示例: retryCount: 2 (最多重试2次,总共可能发起 1+2=3 次请求)。
    • retryDelay (Number, 可选):
      • 作用: 两次重试之间的延迟时间,单位为毫秒
      • 默认值: 1000 (1秒)。
      • 行为: 在发起下一次重试请求前等待指定的时间。
      • 示例: retryDelay: 500 (重试间隔0.5秒)。
    • keepAlive (Boolean, 可选):
      • 作用: 一个标志,用于指示即使请求不被缓存 (ttl <= 0),在请求完成后(进入 .finally 时)是否也要保留其内部记录(主要是 AbortController)。
      • 默认值: false
      • 核心用途: 主要用于 ttl <= 0 的情况。如果 ttl > 0,请求本身就会因为要缓存而被保留,keepAlive 的值不影响 .finally 的主要清理逻辑。当 ttl <= 0 时:
        • keepAlive: true: 请求完成后不立即清理 AbortController。
        • keepAlive: false: 请求完成后立即清理 AbortController。
      • 示例: keepAlive: true
    • autoRefresh (Boolean, 可选):
      • 作用: 是否在请求成功并缓存后,自动在后台安排定时刷新。
      • 默认值: false
      • 行为: 只有当 autoRefresh: true 并且请求结果被成功缓存 (ttl > 0) 时才会生效。
      • 示例: autoRefresh: true
    • refreshInterval (Number, 可选):
      • 作用: 如果 autoRefreshtrue,这个参数指定自动刷新的时间间隔,单位为毫秒
      • 默认值: 30000 (30秒)。
      • 行为: 在请求成功后的指定时间后,会自动重新发起一次相同的请求以更新缓存。
      • 示例: refreshInterval: 60000 (每分钟刷新一次)。

三、 创建自定义实例 new AdvancedRequestPool(options)options 对象参数详解

如果你需要不同于默认 httpPool 的配置,可以自己创建实例。

import AdvancedRequestPool from './AdvancedRequestPool.js';
import { axiosIns } from './httpService.js'; // 假设你的 axios 实例在这里

const customPool = new AdvancedRequestPool({
  defaultTTL: 10 * 60 * 1000, // 默认缓存 10 分钟
  maxSize: 200,             // 最大缓存数量 200
  encryptKey: 'my-very-secret-key-for-cache', // 启用缓存键加密
  retryRules: {              // 自定义重试规则
    networkErrors: true,
    httpStatus: [500, 503], // 只重试 500 和 503
    customCheck: (error) => { // 添加自定义检查
      // 例如:如果错误信息包含 "rate limit", 则不重试
      if (error.message && error.message.includes('rate limit')) {
        return false;
      }
      return undefined; // 返回 undefined 或 null 表示让默认规则继续判断
    }
  },
  enableRegularCleaning: true, // 启用定期清理
  // cleanupFrequency: 120000 // 如果想自定义清理频率 (需要修改类代码添加此选项)
});

// 然后使用 customPool.request(...)
customPool.request({ url: '/api/some-data' })
  .then(/* ... */)
  .catch(/* ... */);
  • defaultTTL (Number, 可选):
    • 作用: 设置该实例下所有请求的默认缓存时间(毫秒)。
    • 默认值: 5000 (5秒)。
  • maxSize (Number, 可选):
    • 作用: 设置 LRU 缓存池的最大容量(条目数)。
    • 默认值: 100
  • encryptKey (String, 可选):
    • 作用: 提供一个字符串密钥,用于对生成的缓存键进行 HMAC-SHA256 加密。如果不提供,则使用原始键。
    • 默认值: null
    • 依赖: 需要项目中安装 crypto-js 库。
  • retryRules (Object, 可选):
    • 作用: 覆盖类静态的 defaultRetryRules,定义该实例的重试判断逻辑。
    • 结构:
      • networkErrors (Boolean): 是否重试网络层错误(如 DNS 解析失败、连接拒绝等,此时 error.response 通常不存在)。
      • httpStatus (Array): 一个包含 HTTP 状态码的数组,当 error.response.status 在此数组中时触发重试。
      • customCheck (Function, 可选): 一个自定义函数 (error) => boolean | undefined | null。它具有最高优先级。如果返回 true 则重试,返回 false 则不重试,返回 undefinednull 则让后续的 networkErrorshttpStatus 规则继续判断。
    • 默认值: AdvancedRequestPool.defaultRetryRules (重试网络错误和 [500, 502, 503, 504, 429, 408] 状态码)。
  • enableRegularCleaning (Boolean, 可选):
    • 作用: 是否启用 setInterval 来定时清理过期的缓存条目。
    • 默认值: false
    • 注意: 定时器默认间隔是 60000 毫秒 (1分钟),目前代码中是硬编码的。

四、 其他方法

  • httpPool.abortRequest(config): 需要传入与原始请求完全相同config 对象(至少包含能生成相同 key 的 method, url, params, data),用于从外部中止一个正在进行的请求。
  • httpPool.destroy(): 清理实例资源,包括停止定时清理、中止所有池中请求、清空缓存池和控制器池。当不再需要该实例时(例如,在单页应用的组件卸载时创建的自定义实例)调用。

五、 重要提醒

  • 依赖: 确保你的项目中安装了 axios, qs, 以及如果使用 encryptKey 则需要 crypto-js。同时,代码依赖于一个正确配置的 axiosIns 实例从 ./httpService.js 导入。
  • 缓存键: generateKeyparamsdata 进行排序和字符串化。这意味着 {a:1, b:2}{b:2, a:1} 会生成相同的键,但 null, undefined 以及对象内部的细微差别可能导致键不同。
  • 资源: AdvancedRequestPool 本身管理状态(Map 对象、定时器),会占用一定的内存。

重试 (Retry)中止 (Abort) 逻辑说明:

一、 重试逻辑补充说明 (Retry Logic Explanation)

重试机制的核心目标是在遇到可恢复的临时性错误(如网络抖动、服务器临时过载)时,自动重新尝试发送请求,以提高请求的成功率。

  1. 触发点与决策:

    • 重试逻辑的起点位于 request 方法内部的 .catch 错误处理块。
    • 当捕获到错误时,首先会调用 this.isRetryableError(error) 来判断:
      • 错误不是由请求中止 (AbortError) 引起的。
      • 错误满足用户配置的 retryRules(默认包括网络错误和特定的 HTTP 状态码如 500, 503 等,或通过 customCheck 自定义)。
    • 同时,检查当前请求配置 config 中的 retryCount 是否大于 0。
    • 只有同时满足 “错误可重试” 且 “剩余重试次数 > 0” 这两个条件,才会进入重试流程。
  2. 委托给 handleRetry:

    • 如果决定重试,.catch 块会执行 return this.handleRetry(key, configForNextRetry, error);
    • 这里的 return 至关重要,它将 request 方法返回的 Promise 与 handleRetry 返回的新 Promise 连接起来。这意味着 request 的最终结果(成功响应或最终错误)将完全由 handleRetry 的执行结果决定。
  3. handleRetry 的职责 (单次重试管理):

    • handleRetry 负责管理一次重试尝试。
    • 它使用 setTimeout 实现延迟(retryDelay),避免立即重试。
    • 在延迟后,它会调用 this.request(configWithDecrementedRetry) 来发起实际的重试请求,注意此时传递的配置中 retryCount 已经减 1。
    • 关键交互 - 更新缓存 Promise 引用:await this.request(...) 之前,handleRetry 会更新缓存池 (this.pool) 中对应 key 的条目,将其 .promise 字段指向新的、正在进行的重试请求的 Promise。这确保了并发请求能获取到最新的尝试状态。
    • 它等待重试请求 (await this.request(...)) 的结果:
      • 成功: 调用 resolve(response),将成功结果沿着 Promise 链传递回去。
      • 失败: 调用 reject(retryError),将这次(可能是最终的)失败错误沿着 Promise 链传递回去。
  4. 重试循环 (request <-> handleRetry):

    • 整个重试过程是一个 requesthandleRetry 之间的间接循环调用:request 失败 -> 调用 handleRetry -> handleRetry 等待后调用 request(次数减 1)-> ...
    • 这个循环会持续,直到某次 request 调用成功,或者达到停止条件(retryCount 耗尽、遇到不可重试错误)。
  5. 缓存与 LRU 更新:

    • 重试进行中: 缓存持有指向最新一次重试尝试的 Pending Promise(由 handleRetry 更新)。
    • 重试成功后: 成功的那个 request 调用内部,会将最终成功的响应存入缓存,并更新该条目的 LRU 位置(标记为最近使用)。

二、 中止逻辑补充说明 (Abort Logic Explanation)

中止逻辑允许你取消一个正在进行中的 HTTP 请求,这对于优化资源使用、处理用户快速切换页面或取消不再需要的操作非常有用。

  1. 核心机制 (AbortController):

    • 利用浏览器或 Node.js 环境提供的标准 AbortController API。
    • 每个可能需要中止的请求,在发起前都会关联一个 AbortController 实例。
  2. 关联与管理:

    • this.abortControllers 是一个 Map,用于存储请求 key 到其对应 AbortController 实例的映射。
    • request 方法内部,如果缓存未命中或过期,会为该 key 获取或创建一个新的 AbortController 并存入 abortControllers
    • 在调用 axiosIns 时,将 controller.signal 作为配置项传递给 Axios (finalConfig.signal = controller.signal;)。Axios 底层会监听这个 signal
  3. 触发中止 (controller.abort()):

    • 显式中止:
      • 通过 Promise: request 方法返回的 promise 对象上附加了一个 .abort() 方法。调用 promise.abort() 会触发关联 controllerabort(),并立即清理缓存池 (pool) 和 abortControllers 中的对应条目。
      • 通过配置: 调用 httpPool.abortRequest(config),它会根据 config 生成 key,找到对应的 controller 并调用其 abort(),同样会立即清理相关状态。
    • 隐式中止 (自动清理):
      • 缓存过期:request 方法检查缓存或 cleanup 方法执行时,发现缓存条目过期,会调用 entry.promise.abort?.() 尝试中止,并清理状态。
      • LRU 淘汰: 当缓存池满进行 LRU 淘汰时,被淘汰条目关联的 promise.abort?.() 会被调用,并清理状态。
      • 实例销毁: 调用 httpPool.destroy() 时,会遍历所有 abortControllers 并调用 controller.abort(),然后清空所有映射。
  4. 中止的后果:

    • controller.abort() 被调用时,signal 会发出中止信号。
    • Axios 监听到信号后,会取消底层的 HTTP 请求。
    • Axios 请求的 Promise 会被 reject,并抛出一个特定类型的错误(通常是 AbortError 或 Axios 的 CanceledError)。
    • request 方法内部的 .catch 块会捕获这个错误。
  5. 中止与重试的关系:

    • isRetryableError 方法明确检查 isAbortError(error)。如果错误是中止错误,isRetryableError 返回 false
    • 这意味着被中止的请求绝对不会触发重试逻辑。这是符合预期的行为,因为中止通常意味着该请求不再被需要。

总结:

  • 重试是为了提高请求在临时性、可恢复错误下的成功率,通过 request -> handleRetry -> request 的循环实现多次尝试。
  • 中止是为了取消不再需要或正在进行的请求,利用 AbortController 实现,可以在多种场景下(手动、过期、淘汰、销毁)触发,并阻止后续的重试。

这两个机制共同为网络请求增加了健壮性和可控性。


Axios 内部是如何利用传入的 controller.signal 来实现中止功能的?

核心流程分解:

  1. 信号的传递 (我的代码部分):

    • 当我调用 httpPool.request(config) 时,代码会为这个请求(由 key 标识)找到或创建一个 AbortController 实例 (controller)。
    • 然后,它获取这个控制器的信号对象:controller.signal
    • 这个 signal 对象被放入了最终传递给 axiosIns 的配置对象 finalConfig 中:
      const finalConfig = {
        ...config,
        signal: controller.signal, // <--- 信号在这里被传递
      };
      const promise = axiosIns(finalConfig) // <--- Axios 接收到配置
         // ... .then / .catch / .finally
      
  2. Axios 内部接收信号 (Axios 库的内部逻辑 - 概念性描述):

    • axiosIns(finalConfig) 被调用时,Axios 内部的代码会检查 finalConfig 对象。
    • 它发现里面有一个 signal 属性,并且值是一个 AbortSignal 对象 (controller.signal)。
    • 关键动作 1: 添加监听器: Axios 会立即在这个 signal 对象上注册一个事件监听器,专门监听 'abort' 事件。可以想象成 Axios 内部有类似这样的代码(简化示意):
      // (Inside Axios, when processing the config containing a signal)
      const signal = finalConfig.signal;
      let internalAbortHandler; // 用于后续移除监听
      
      if (signal) {
          // 检查信号是否已经中止了 (比如 controller 在请求发起前就被 abort 了)
          if (signal.aborted) {
              // 如果已经中止,直接创建一个 AbortError/CanceledError 并 reject Axios 返回的 Promise
              const abortError = createAbortError(signal.reason);
              rejectAxiosPromise(abortError); // 假设这是 reject Axios Promise 的内部函数
              return; // 不再继续发送请求
          }
      
          // 如果信号还没中止,设置监听器
          internalAbortHandler = () => {
              // 当 'abort' 事件触发时,这个函数会被调用
              console.log("Axios Internal: 'abort' event received!"); // 调试信息
      
              // 1. 取消底层的 HTTP 请求
              cancelUnderlyingHttpRequest(); // 假设这是调用 XHR.abort() 或 http.ClientRequest.abort() 的内部函数
      
              // 2. 创建一个表示中止的错误
              const abortError = createAbortError(signal.reason); // 使用 signal 中的 reason
      
              // 3. Reject Axios 返回给你的那个 Promise
              rejectAxiosPromise(abortError);
          };
      
          // 绑定监听
          signal.addEventListener('abort', internalAbortHandler, { once: true }); // once: true 表示事件触发一次后自动移除监听
      }
      
      // ... 继续正常的发送 HTTP 请求的流程 ...
      // ... 在请求正常完成或失败(非中止)后,需要移除监听器 ...
      if (signal && internalAbortHandler) {
          // 无论请求成功还是失败(非中止),都需要清理监听器,防止内存泄漏
          // This cleanup happens in Axios's internal .then/.catch/finally logic for the HTTP request itself
           signal.removeEventListener('abort', internalAbortHandler);
       }
      
  3. 中止操作触发 (我的代码部分):

    • 假设请求正在进行中。在我的代码的其他地方,因为某种原因(用户点击取消、页面跳转、缓存淘汰等),调用了与这个请求关联的 controller.abort()。例如:
      // 情况 A: 用户点击了取消按钮,调用了 promise.abort()
      const requestPromise = httpPool.request(someConfig);
      // ... later ...
      requestPromise.abort(); // 这会调用 controller.abort()
      
      // 情况 B: 缓存淘汰
      // ... (in request method, when pool is full) ...
      lruEntry?.promise.abort?.(); // 这也可能调用 controller.abort()
      
      // 情况 C: 手动调用 abortRequest
      httpPool.abortRequest(someConfig); // 这会找到 controller 并调用 abort()
      
  4. 信号触发与 Axios 响应 (联动效果):

    • controller.abort() 被调用。
    • controller.signalaborted 状态变为 true
    • controller.signal 立即触发 'abort' 事件
    • 关键动作 2: Axios 监听到事件: Axios 内部注册的 internalAbortHandler 函数(在步骤 2 中添加的)被调用。
    • Axios 的反应:
      • internalAbortHandler 首先会尝试取消底层真正发送的网络请求(比如浏览器的 XMLHttpRequest 或 Node.js 的 http.ClientRequest)。
      • 然后,它会创建一个表示请求被中止的特定错误对象(如 AbortError 或 Axios 的 CanceledError)。
      • 最重要的是,它会 reject 那个由 axiosIns(finalConfig) 调用最初返回给你的 promise
  5. 错误传递回我的代码:

    • 因为 Axios 的 Promise 被 reject 了,执行流会进入我 request 方法中的 .catch 块:
      .catch((error) => { // <--- 这里接收到 Axios reject 的错误
        // 检查是否是中止错误
        if (this.isRetryableError(error)) { // isRetryableError 内部会调用 isAbortError
           // ... 重试逻辑 ...
        } else {
           // 如果是 AbortError,isRetryableError 会返回 false
           // 因此会执行到这里
           console.log("Caught error, likely AbortError:", error.name); // 可以看到错误名称
           throw error; // 将 AbortError 继续抛出,或者根据业务处理
        }
      })
      
    • 我的 isRetryableError 方法会首先检查 this.isAbortError(error),此时它会返回 true
    • 因此,isRetryableError 整体返回 false
    • .catch 块的 if 条件不满足,执行 else 分支(如果写了的话)或者直接执行 throw error;
    • 最终,最初调用 httpPool.request(config) 的地方,如果使用了 await.catch,就会捕获到这个表示请求被中止的 AbortErrorCanceledError

总结起来:

controller.signal 就像一个电线,我把一端 (signal) 交给 Axios,另一端 (controller) 留在我手里。当我按下开关 (controller.abort()),电流通过电线 ('abort' 事件),Axios 检测到电流,就中断它正在做的工作(HTTP 请求),并告诉我(通过 reject Promise)它已经停止了。我的代码则通过检查收到的错误类型 (isAbortError) 来识别出这是由于中止操作导致的失败。