进阶篇代码使用的缓存淘汰策略是 FIFO (First-In, First-Out),因为它在缓存满时总是删除 this.pool.keys().next().value
,即最早插入 Map
的条目。
我将其优化为 LRU (Least Recently Used - 最近最少使用) 策略。LRU 的核心思想是:当缓存满时,优先淘汰最长时间未被访问(读取或写入)的条目。
优化方法:利用 Map
的特性模拟 LRU
JavaScript 的 Map
会记住元素的插入顺序。我们可以利用这一点,在每次访问(get
或 set
)缓存时,将被访问的条目重新插入到 Map
中,这样它就会被移动到迭代顺序的末尾,代表“最近使用”。当需要淘汰时,我们仍然删除迭代顺序的第一个元素 (keys().next().value
),此时它代表的就是“最久未使用”的条目。
主要修改点解释:
-
缓存命中 (
request
方法内):- 当找到有效缓存 (
this.pool.has(key)
且未过期) 时: - 先
this.pool.delete(key)
删除该条目。 - 再
this.pool.set(key, entry)
将其重新添加。 - 这利用了
Map
按插入顺序迭代的特性,将刚访问的条目移动到了内部顺序的末尾,标记为“最近使用”。
- 当找到有效缓存 (
-
缓存淘汰 (
request
方法内):- 检查
this.pool.size >= this.maxSize
的逻辑不变。 - 获取
const lruKey = this.pool.keys().next().value;
也不变。 - 但此时,由于命中时会将元素移到末尾,所以
keys().next().value
获取到的实际上是最久未被访问(读取或添加/更新)的键,即 LRU 键。 - 删除
lruKey
对应的条目、中止其 Promise、删除其 AbortController。
- 检查
-
添加新条目 (
request
方法内):- 执行
this.pool.set(key, ...)
时,新条目会被自动添加到Map
的末尾,自然成为“最新”的条目。
- 执行
-
处理过期 (
request
方法内):- 当发现缓存存在但已过期时,明确地从
pool
和abortControllers
中删除该条目,避免它作为“最久未使用”的项被错误地保留在Map
的开头。
- 当发现缓存存在但已过期时,明确地从
-
重试逻辑 (
handleRetry
):- 调整了
handleRetry
的调用方式,传递递减后的重试次数配置。 - 在
handleRetry
内部重新发起this.request
时,如果请求成功,这个request
调用会像正常添加新条目一样,将成功的 Promise 放入pool
,并更新其 LRU 位置。 - 如果重试的
this.request
命中了缓存(理论上不太可能,因为是刚失败的请求,但逻辑上要考虑),也会更新 LRU 位置。 - 添加了在
handleRetry
内部更新pool
中 Promise 的逻辑,确保缓存持有的是最新的重试 Promise。
- 调整了
-
清理和中止:
- 确保在
abort
,cleanup
,destroy
, 以及淘汰逻辑中,都同时清理this.pool
和this.abortControllers
中的对应条目。
- 确保在
-
日志: 添加了一些
console.log
来帮助追踪缓存命中、未命中、过期、淘汰和重试等行为,方便调试。上线时可以移除或使用更完善的日志库。 -
表单忽略: 因为表单可能存在文件,所以将整个 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, 可选):- 作用: 如果
autoRefresh
为true
,这个参数指定自动刷新的时间间隔,单位为毫秒。 - 默认值:
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
则不重试,返回undefined
或null
则让后续的networkErrors
和httpStatus
规则继续判断。
- 默认值:
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
导入。 - 缓存键:
generateKey
对params
和data
进行排序和字符串化。这意味着{a:1, b:2}
和{b:2, a:1}
会生成相同的键,但null
,undefined
以及对象内部的细微差别可能导致键不同。 - 资源:
AdvancedRequestPool
本身管理状态(Map 对象、定时器),会占用一定的内存。
重试 (Retry) 和中止 (Abort) 逻辑说明:
一、 重试逻辑补充说明 (Retry Logic Explanation)
重试机制的核心目标是在遇到可恢复的临时性错误(如网络抖动、服务器临时过载)时,自动重新尝试发送请求,以提高请求的成功率。
-
触发点与决策:
- 重试逻辑的起点位于
request
方法内部的.catch
错误处理块。 - 当捕获到错误时,首先会调用
this.isRetryableError(error)
来判断:- 错误不是由请求中止 (
AbortError
) 引起的。 - 错误满足用户配置的
retryRules
(默认包括网络错误和特定的 HTTP 状态码如 500, 503 等,或通过customCheck
自定义)。
- 错误不是由请求中止 (
- 同时,检查当前请求配置
config
中的retryCount
是否大于 0。 - 只有同时满足 “错误可重试” 且 “剩余重试次数 > 0” 这两个条件,才会进入重试流程。
- 重试逻辑的起点位于
-
委托给
handleRetry
:- 如果决定重试,
.catch
块会执行return this.handleRetry(key, configForNextRetry, error);
。 - 这里的
return
至关重要,它将request
方法返回的 Promise 与handleRetry
返回的新 Promise 连接起来。这意味着request
的最终结果(成功响应或最终错误)将完全由handleRetry
的执行结果决定。
- 如果决定重试,
-
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 链传递回去。
- 成功: 调用
-
重试循环 (
request
<->handleRetry
):- 整个重试过程是一个
request
和handleRetry
之间的间接循环调用:request
失败 -> 调用handleRetry
->handleRetry
等待后调用request
(次数减 1)-> ... - 这个循环会持续,直到某次
request
调用成功,或者达到停止条件(retryCount
耗尽、遇到不可重试错误)。
- 整个重试过程是一个
-
缓存与 LRU 更新:
- 重试进行中: 缓存持有指向最新一次重试尝试的 Pending Promise(由
handleRetry
更新)。 - 重试成功后: 成功的那个
request
调用内部,会将最终成功的响应存入缓存,并更新该条目的 LRU 位置(标记为最近使用)。
- 重试进行中: 缓存持有指向最新一次重试尝试的 Pending Promise(由
二、 中止逻辑补充说明 (Abort Logic Explanation)
中止逻辑允许你取消一个正在进行中的 HTTP 请求,这对于优化资源使用、处理用户快速切换页面或取消不再需要的操作非常有用。
-
核心机制 (
AbortController
):- 利用浏览器或 Node.js 环境提供的标准
AbortController
API。 - 每个可能需要中止的请求,在发起前都会关联一个
AbortController
实例。
- 利用浏览器或 Node.js 环境提供的标准
-
关联与管理:
this.abortControllers
是一个Map
,用于存储请求key
到其对应AbortController
实例的映射。- 在
request
方法内部,如果缓存未命中或过期,会为该key
获取或创建一个新的AbortController
并存入abortControllers
。 - 在调用
axiosIns
时,将controller.signal
作为配置项传递给 Axios (finalConfig.signal = controller.signal;
)。Axios 底层会监听这个signal
。
-
触发中止 (
controller.abort()
):- 显式中止:
- 通过 Promise:
request
方法返回的promise
对象上附加了一个.abort()
方法。调用promise.abort()
会触发关联controller
的abort()
,并立即清理缓存池 (pool
) 和abortControllers
中的对应条目。 - 通过配置: 调用
httpPool.abortRequest(config)
,它会根据config
生成key
,找到对应的controller
并调用其abort()
,同样会立即清理相关状态。
- 通过 Promise:
- 隐式中止 (自动清理):
- 缓存过期: 在
request
方法检查缓存或cleanup
方法执行时,发现缓存条目过期,会调用entry.promise.abort?.()
尝试中止,并清理状态。 - LRU 淘汰: 当缓存池满进行 LRU 淘汰时,被淘汰条目关联的
promise.abort?.()
会被调用,并清理状态。 - 实例销毁: 调用
httpPool.destroy()
时,会遍历所有abortControllers
并调用controller.abort()
,然后清空所有映射。
- 缓存过期: 在
- 显式中止:
-
中止的后果:
- 当
controller.abort()
被调用时,signal
会发出中止信号。 - Axios 监听到信号后,会取消底层的 HTTP 请求。
- Axios 请求的 Promise 会被 reject,并抛出一个特定类型的错误(通常是
AbortError
或 Axios 的CanceledError
)。 request
方法内部的.catch
块会捕获这个错误。
- 当
-
中止与重试的关系:
isRetryableError
方法明确检查isAbortError(error)
。如果错误是中止错误,isRetryableError
返回false
。- 这意味着被中止的请求绝对不会触发重试逻辑。这是符合预期的行为,因为中止通常意味着该请求不再被需要。
总结:
- 重试是为了提高请求在临时性、可恢复错误下的成功率,通过
request
->handleRetry
->request
的循环实现多次尝试。 - 中止是为了取消不再需要或正在进行的请求,利用
AbortController
实现,可以在多种场景下(手动、过期、淘汰、销毁)触发,并阻止后续的重试。
这两个机制共同为网络请求增加了健壮性和可控性。
Axios 内部是如何利用传入的 controller.signal
来实现中止功能的?
核心流程分解:
-
信号的传递 (我的代码部分):
- 当我调用
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
- 当我调用
-
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); }
- 当
-
中止操作触发 (我的代码部分):
- 假设请求正在进行中。在我的代码的其他地方,因为某种原因(用户点击取消、页面跳转、缓存淘汰等),调用了与这个请求关联的
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()
- 假设请求正在进行中。在我的代码的其他地方,因为某种原因(用户点击取消、页面跳转、缓存淘汰等),调用了与这个请求关联的
-
信号触发与 Axios 响应 (联动效果):
controller.abort()
被调用。controller.signal
的aborted
状态变为true
。controller.signal
立即触发'abort'
事件。- 关键动作 2: Axios 监听到事件: Axios 内部注册的
internalAbortHandler
函数(在步骤 2 中添加的)被调用。 - Axios 的反应:
internalAbortHandler
首先会尝试取消底层真正发送的网络请求(比如浏览器的XMLHttpRequest
或 Node.js 的http.ClientRequest
)。- 然后,它会创建一个表示请求被中止的特定错误对象(如
AbortError
或 Axios 的CanceledError
)。 - 最重要的是,它会 reject 那个由
axiosIns(finalConfig)
调用最初返回给你的promise
。
-
错误传递回我的代码:
- 因为 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
,就会捕获到这个表示请求被中止的AbortError
或CanceledError
。
- 因为 Axios 的 Promise 被 reject 了,执行流会进入我
总结起来:
controller.signal
就像一个电线,我把一端 (signal
) 交给 Axios,另一端 (controller
) 留在我手里。当我按下开关 (controller.abort()
),电流通过电线 ('abort'
事件),Axios 检测到电流,就中断它正在做的工作(HTTP 请求),并告诉我(通过 reject Promise)它已经停止了。我的代码则通过检查收到的错误类型 (isAbortError
) 来识别出这是由于中止操作导致的失败。