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

进阶篇代码使用的缓存淘汰策略是 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),此时它代表的就是"最久未使用"的条目。

修改后的代码:

javascript 复制代码
// 这里我引入的是axios已经创建的实例,这样可以和现有项目完美结合
import { axiosIns } from "./httpService.js";
import { stringify } from "qs";
import CryptoJS from "crypto-js";

class AdvancedRequestPool {
  constructor(options = {}) {
    this.pool = new Map(); // 请求池,用于存储请求的键值对 (现在实现 LRU)
    this.defaultTTL = options.defaultTTL || 5000; // 默认缓存时间(毫秒)
    this.maxSize = options.maxSize || 100; // 请求池的最大大小
    this.encryptKey = options.encryptKey || null; // 可选的加密密钥

    this.abortControllers = new Map(); // 存储 AbortController 引用

    this.retryRules =
      options.retryRules || AdvancedRequestPool.defaultRetryRules;

    this.enableRegularCleaning = options.enableRegularCleaning || false;
    if (this.enableRegularCleaning)
      this.cleanupInterval = setInterval(this.cleanup.bind(this), 60000);
  }

  // --- 其他方法保持不变 ---
  static defaultRetryRules = {
    networkErrors: true,
    httpStatus: [500, 502, 503, 504, 429, 408],
    customCheck: null,
  };

  isAbortError(error) {
    return (
      error.name === "AbortError" ||
      (error.response && error.response.status === 499)
    );
  }

  isRetryableError(error) {
    if (this.isAbortError(error)) return false;
    if (this.retryRules.customCheck) {
      return this.retryRules.customCheck(error);
    }
    if (this.retryRules.networkErrors && !error.response) return true;
    if (error.response) {
      const status = error.response.status;
      return this.retryRules.httpStatus.includes(status);
    }
    return false;
  }

  generateKey(config) {
    const { method, url, params, data } = config;
    const sortedParams = stringify(params, {
      sort: (a, b) => a.localeCompare(b),
    });
    const sortedData =
      typeof data === "object"
        ? stringify(data, { sort: (a, b) => a.localeCompare(b) })
        : data;
    const rawKey = `${method}-${url}-${sortedParams}-${sortedData}`;
    return this.encryptKey
      ? CryptoJS.HmacSHA256(rawKey, this.encryptKey).toString()
      : rawKey;
  }

  // 发送请求 - 重点修改部分
  async request(config) {
    const key = this.generateKey(config);
    const now = Date.now();

    // --- LRU 优化点 1: 缓存命中时更新其位置 ---
    if (this.pool.has(key)) {
      const entry = this.pool.get(key);
      if (entry.expire > now) {
        // --- LRU 核心: 将命中的条目移到末尾 (最近使用) ---
        this.pool.delete(key); // 先删除
        this.pool.set(key, entry); // 再添加,使其成为最新的
        // --- LRU 核心结束 ---
        console.log(`Cache hit for key: ${key}`); // 可选:日志记录
        return entry.promise; // 返回缓存的 Promise
      } else {
        // 缓存存在但已过期
        console.log(`Cache expired for key: ${key}`); // 可选:日志记录
        entry.promise.abort?.(); // 中止旧的请求(如果还在进行中)
        this.pool.delete(key); // 从缓存池中删除过期的条目
        this.abortControllers.delete(key); // 也删除对应的 AbortController
      }
    }

    // --- 缓存未命中 或 缓存已过期 ---
    console.log(`Cache miss or expired for key: ${key}`); // 可选:日志记录

    // --- LRU 优化点 2: 缓存淘汰逻辑 (淘汰最久未使用的) ---
    // 在添加新条目 *之前* 检查大小,如果需要则淘汰
    if (this.pool.size >= this.maxSize) {
      // Map 的 keys().next().value 返回的是最早插入的键
      // 因为我们在命中时会重新插入,所以最早插入的 == 最久未使用的
      const lruKey = this.pool.keys().next().value;
      console.log(`Cache full, evicting LRU key: ${lruKey}`); // 可选:日志记录
      const lruEntry = this.pool.get(lruKey);
      lruEntry?.promise.abort?.(); // 尝试中止被淘汰的请求
      this.pool.delete(lruKey); // 从缓存池淘汰
      this.abortControllers.delete(lruKey); // 从控制器映射中淘汰
    }
    // --- LRU 淘汰逻辑结束 ---

    // --- 创建和管理 AbortController ---
    let controller = this.abortControllers.get(key);
    // 如果因为过期或其他原因已被删除,则重新创建
    if (!controller) {
        controller = new AbortController();
        this.abortControllers.set(key, controller);
    }
    // --- AbortController 管理结束 ---


    const finalConfig = {
      ...config,
      signal: controller.signal,
    };

    const promise = axiosIns(finalConfig)
      .then((response) => {
        // 成功后,如果配置了自动刷新,安排刷新
        if (config.autoRefresh) {
          this.scheduleRefresh(key, config);
        }
        return response;
      })
      .catch((error) => {
        // 处理重试逻辑
        // 注意:使用请求配置中的 retryCount,并且需要递减
        const currentRetryCount = config.retryCount ?? 0;
        if (currentRetryCount > 0 && this.isRetryableError(error)) {
             // 创建一个包含递减后重试次数的新配置对象,用于下一次重试
             const configForNextRetry = {
                 ...config, // 保持原始配置的其他部分
                 retryCount: currentRetryCount - 1 // 递减重试次数
             };
             return this.handleRetry(key, configForNextRetry, error); // 传递递减后的配置
        }
        // 如果没有重试次数或错误不可重试,则抛出错误
        // console.error(`Request failed permanently for key ${key} after exhausting retries or due to non-retryable error.`, error);
        throw error; // 抛出错误,终止 Promise 链
      })
      .finally(() => {
        // 如果请求不需要保持活动状态,则在完成后清理
        // 注意:如果启用了缓存 TTL,这里不应该删除,让 cleanup 或下次访问时处理
        if (!config.keepAlive && !(config.cacheTTL > 0 || this.defaultTTL > 0)) {
            this.pool.delete(key);
            this.abortControllers.delete(key);
        } else if (!this.pool.has(key) && this.abortControllers.has(key)) {
            // 特殊情况:如果在finally之前被abort了,确保控制器也被清理
            // console.log(`Cleaning up orphan AbortController for key: ${key}`);
            this.abortControllers.delete(key);
        }
      });

    // 为 Promise 添加 abort 方法
    promise.abort = () => {
      controller.abort();
      // abort 时也应该清理缓存和控制器引用
      this.pool.delete(key);
      this.abortControllers.delete(key);
      console.log(`Request aborted for key: ${key}`); // 可选:日志记录
    };

    // 将新的请求 Promise 存入缓存池
    // --- LRU 优化点 3: 新条目添加到末尾 ---
    this.pool.set(key, {
      promise,
      expire: Date.now() + (config.cacheTTL || this.defaultTTL),
      config, // 存储本次请求的配置
    });
    // 新条目自然被添加到 Map 的末尾,成为"最新"的
    // --- LRU 添加逻辑结束 ---

    return promise;
  }


  // 清理过期的请求 - 无需改变
  cleanup() {
    const now = Date.now();
    let cleanedCount = 0; // 可选:计数
    this.pool.forEach((value, key) => {
      if (value.expire < now) {
        value.promise.abort?.();
        this.pool.delete(key);
        this.abortControllers.delete(key);
        cleanedCount++;
      }
    });
    if (cleanedCount > 0) {
        console.log(`Cleaned up ${cleanedCount} expired cache entries.`); // 可选:日志
    }
  }

  // 安排自动刷新 - 无需改变,但注意它会重新调用 request,从而更新 LRU 顺序
  scheduleRefresh(key, config) {
    // 在安排前检查缓存是否仍然存在,避免不必要的定时器
    if (this.pool.has(key)) {
        const refreshInterval = config.refreshInterval || 30000;
        console.log(`Scheduling refresh for key: ${key} in ${refreshInterval}ms`); // 可选:日志
        setTimeout(() => {
          // 再次检查,可能在等待期间被删除了
          if (this.pool.has(key)) {
            console.log(`Refreshing request for key: ${key}`); // 可选:日志
            // 注意:旧的 promise 不需要手动 abort,因为 request 方法内部
            // 在覆盖缓存前会检查并 abort 过期或需要替换的 promise
            this.request({ ...config, keepAlive: true }); // 重新请求,标记为 keepAlive
          }
        }, refreshInterval);
    }
  }

  // 处理重试逻辑 - 稍作调整以接收递减后的配置
  async handleRetry(key, configWithDecrementedRetry, error) {
    console.log(`Attempting retry for key: ${key}. Retries left: ${configWithDecrementedRetry.retryCount}`); // 可选:日志

    // 无需再次检查 isRetryableError,因为调用前已经检查过了

    return new Promise((resolve, reject) => {
      setTimeout(async () => {
        try {
          // 使用带有递减重试次数的配置重新发起请求
          const retryPromise = this.request(configWithDecrementedRetry);

          // --- 更新缓存中的 Promise ---
          // 检查缓存条目是否仍然存在(可能在延迟期间被清理或中止)
          if (this.pool.has(key)) {
              const entry = this.pool.get(key);
              // 只有当缓存中的 Promise 还是原来的那个失败的 Promise 时才更新
              // (虽然理论上不太可能,但以防万一)
              // 注意:这里比较 Promise 引用可能不可靠,更稳妥的方式是直接更新
              this.pool.set(key, {
                ...entry, // 保留 expire 和 config
                promise: retryPromise, // 更新为重试的 Promise
              });
          }
           // --- 更新缓存结束 ---

          resolve(await retryPromise);
        } catch (retryError) {
          // 如果重试仍然失败
          console.error(`Retry failed for key: ${key}`, retryError); // 可选:日志
          // 将最终的错误 reject 出去,传递给调用者
          reject(retryError); 
        }
      }, configWithDecrementedRetry.retryDelay || 1000);
    });
  }

  // 添加全局中止方法 - 无需改变
  abortRequest(config) {
    const key = this.generateKey(config);
    const controller = this.abortControllers.get(key);
    if (controller) {
        console.log(`Aborting request via abortRequest for key: ${key}`); // 可选:日志
        controller.abort();
        // Abort 时也应该清理缓存池
        this.pool.delete(key);
        this.abortControllers.delete(key); // 确保控制器也被移除
    }
  }

  // 销毁实例,清理所有资源 - 无需改变
  destroy() {
    if (this.enableRegularCleaning) clearInterval(this.cleanupInterval);
    this.pool.forEach((value, key) => {
      value.promise.abort?.();
      this.abortControllers.delete(key); // 确保清理控制器
    });
    this.pool.clear();
    this.abortControllers.clear(); // 再次确保清空
    console.log("AdvancedRequestPool instance destroyed."); // 可选:日志
  }
}

// --- 实例化和导出 ---
const httpPool = new AdvancedRequestPool({
    // 可以在这里配置 maxSize, defaultTTL, retryRules 等
    maxSize: 50, // 例如,设置最大缓存为 50
    defaultTTL: 1000 * 60 * 5, // 例如,默认缓存 5 分钟
    enableRegularCleaning: true, // 启用定时清理
});

export default AdvancedRequestPool; // 导出类本身
export { httpPool }; // 导出默认实例

主要修改点解释:

  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 来帮助追踪缓存命中、未命中、过期、淘汰和重试等行为,方便调试。上线时可以移除或使用更完善的日志库。

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

相关推荐
恋猫de小郭24 分钟前
Android Studio Cloud 正式上线,不只是 Android,随时随地改 bug
android·前端·flutter
清岚_lxn5 小时前
原生SSE实现AI智能问答+Vue3前端打字机流效果
前端·javascript·人工智能·vue·ai问答
ZoeLandia5 小时前
Element UI 设置 el-table-column 宽度 width 为百分比无效
前端·ui·element-ui
橘子味的冰淇淋~6 小时前
解决 vite.config.ts 引入scss 预处理报错
前端·vue·scss
小小小小宇8 小时前
V8 引擎垃圾回收机制详解
前端
lauo8 小时前
智体知识库:ai-docs对分布式智体编程语言Poplang和javascript的语法的比较(知识库问答)
开发语言·前端·javascript·分布式·机器人·开源
拉不动的猪8 小时前
设计模式之------单例模式
前端·javascript·面试
一袋米扛几楼988 小时前
【React框架】什么是 Vite?如何使用vite自动生成react的目录?
前端·react.js·前端框架
Alt.98 小时前
SpringMVC基础二(RestFul、接收数据、视图跳转)
java·开发语言·前端·mvc
进取星辰9 小时前
1、从零搭建魔法工坊:React 19 新手村生存指南
前端·react.js·前端框架