// src/utils/imageCacheManager.js import { IMAGE_CACHE_VERSION } from '@/config/imageCacheVersion' import { useDebounce } from '@/utils/useDebounce' class ImageCacheManager { constructor() { this.cacheVersionKey = 'h5_app_version' this.currentAppVersion = import.meta.env.VITE_APP_VERSION || '1.0.0' this.currentImageVersion = IMAGE_CACHE_VERSION.version // 使用完整版本号 // 移除了 this.versionType = import.meta.env.VITE_VERSION_TYPE || 'patch' this.imageCacheName = `likei-images-v${this.currentImageVersion}` // OSS图片 this.assetCacheName = `likei-assets-v${this.currentImageVersion}` // 本地assets this.maxCacheItems = 200 // 最大缓存数量(200张) this.maxCacheSize = 200 * 1024 * 1024 // 最大缓存大小(200MB) this.currentImageCacheSize = 0 // 当前image缓存大小(字节) this.currentAssetCacheSize = 0 // 当前asset缓存大小(字节) this.requestScheduler = new Map() // 上层请求调度器: CORS 降级处理 (Promise 或 error) this.pendingRequests = new Map() // 底层请求管理:包含重试次数 { url: { promise, retries } } this.maxRetries = 3 // 最大重试次数 // ✅ 新增:防抖版本的缓存限制检查(1 秒防抖,合并多次调用) this.enforceCacheLimitsDebounced = useDebounce( this.enforceCacheLimits.bind(this), // ⚠️ 只绑定 this,不绑定参数 1000, false, ) this.init() } // 解析版本号为数组 [major, minor, patch] parseVersion(version) { return version.split('.').map(Number) } // 比较两个版本号,返回是否版本发生了变化(任何部分的变化) isVersionChanged(cachedVersion, currentVersion) { return cachedVersion !== currentVersion } // 检查是否是major版本更新 isMajorVersionUpdate(cachedVersion, currentVersion) { const [cachedMajor] = this.parseVersion(cachedVersion) const [currentMajor] = this.parseVersion(currentVersion) return currentMajor > cachedMajor } // 返回对应的缓存 async getCacheForUrl(url) { // 规范化 URL const normalizedUrl = this.normalizeUrl(url) let cacheName if ( normalizedUrl.startsWith('https://tkm-likei.oss-ap-southeast-1.aliyuncs.com/h5/Azizi/') || normalizedUrl.includes('/oss/h5/Azizi/') ) { cacheName = this.imageCacheName } else if (normalizedUrl.includes('/assets/')) { cacheName = this.assetCacheName } else { // 其他外部链接放入通用图片缓存 cacheName = this.imageCacheName } try { const cache = await caches.open(cacheName) return { cache, cacheName } // 返回包含缓存和名称的对象 } catch (error) { console.error(`打开缓存失败: ${cacheName}`, error) throw error } } async init() { await this.checkVersionAndUpdate() } // 版本检查逻辑 async checkVersionAndUpdate() { const cachedVersion = localStorage.getItem(this.cacheVersionKey) const cachedImageVersion = localStorage.getItem('likei-image-version') if (!cachedVersion) { // 首次安装:保存当前版本和图片版本 localStorage.setItem(this.cacheVersionKey, this.currentAppVersion) localStorage.setItem('likei-image-version', IMAGE_CACHE_VERSION.version) return } // 检查是否是major版本更新 const isMajorUpdate = this.isMajorVersionUpdate(cachedVersion, this.currentAppVersion) // major版本更新:清除所有缓存 if (isMajorUpdate) { console.log(`major版本更新:清除所有缓存`) await this.clearAllCaches() } // 非major版本更新:检查图片版本是否变化 else { const isImageVersionChanged = this.isVersionChanged( cachedImageVersion || '1.0.0', IMAGE_CACHE_VERSION.version, ) // 图片版本变化:根据配置清除指定类型的缓存 if (isImageVersionChanged) { console.log(`非major版本更新:清除缓存类型: ${IMAGE_CACHE_VERSION.cacheType}`) await this.clearCacheByType(IMAGE_CACHE_VERSION.cacheType) } // 如果图片版本未变化且不是major更新,则不执行任何清除操作 } // 更新版本号 localStorage.setItem(this.cacheVersionKey, this.currentAppVersion) localStorage.setItem('likei-image-version', IMAGE_CACHE_VERSION.version) } // 根据类型清除缓存 async clearCacheByType(cacheType) { switch (cacheType) { case 'images': await this.clearOldImageCaches() break case 'assets': await this.clearOldAssetCaches() break case 'all': await this.clearAllCaches() break default: // 默认清除全部图片缓存 await this.clearAllCaches() break } } // 更新指定缓存的大小 _updateCacheSize(cacheName, deltaSize) { if (cacheName === this.imageCacheName) { this.currentImageCacheSize += deltaSize } else if (cacheName === this.assetCacheName) { this.currentAssetCacheSize += deltaSize } } // 检查并清理超出限制的缓存 async enforceCacheLimits(cache, cacheName) { try { // 快速判断:根据缓存名称选择对应的大小属性 let currentCacheSize if (cacheName === this.imageCacheName) { currentCacheSize = this.currentImageCacheSize } else if (cacheName === this.assetCacheName) { currentCacheSize = this.currentAssetCacheSize } else { console.warn(`未知缓存名称: ${cacheName}`) return } // 如果当前缓存大小未超过限制,直接返回 if (currentCacheSize <= this.maxCacheSize) { const keys = await cache.keys() if (keys.length <= this.maxCacheItems) { return } } // 超限情况:获取详细缓存项信息并清理 const keys = await cache.keys() const cacheEntries = [] for (const key of keys) { try { const response = await cache.match(key) if (response) { // ✅ 优化:优先使用 Content-Length 头,避免 blob() 开销 const contentLength = response.headers.get('content-length') // 使用与添加时相同的兜底逻辑 const size = contentLength ? parseInt(contentLength, 10) : (await response.blob()).size // 兜底方案 cacheEntries.push({ key, size, response }) } } catch (e) { console.warn('无法获取缓存项大小:', key.url) } } let totalItems = cacheEntries.length let totalSize = cacheEntries.reduce((sum, entry) => sum + entry.size, 0) let itemsToRemoveCount = Math.max(0, totalItems - this.maxCacheItems) let sizeToRemove = Math.max(0, totalSize - this.maxCacheSize) let itemsToDelete = [] if (itemsToRemoveCount > 0 || sizeToRemove > 0) { let removedSize = 0 let removedCount = 0 for (const entry of cacheEntries) { if (removedCount >= itemsToRemoveCount && totalSize - removedSize <= this.maxCacheSize) { break } itemsToDelete.push(entry.key) removedSize += entry.size removedCount++ } } // 执行删除操作并更新缓存大小 for (const key of itemsToDelete) { const response = await cache.match(key) if (response) { const contentLength = response.headers.get('content-length') if (contentLength) { // 使用与添加时相同的逻辑 const size = contentLength ? parseInt(contentLength, 10) : (await response.blob()).size // 兜底 this._updateCacheSize(cacheName, -size) // 减少缓存大小 } } await cache.delete(key) } } catch (error) { console.warn('检查缓存限制时出错:', error) } } // 清除旧的OSS图片缓存 async clearOldImageCaches() { if (!('caches' in window)) return try { const cacheNames = await caches.keys() const oldImageCaches = cacheNames.filter( (name) => name.startsWith('likei-images-v') && !name.includes(`v${this.currentImageVersion}`), // 排除当前版本的缓存 ) console.log('正在清除旧的OSS图片缓存:', oldImageCaches) await Promise.all(oldImageCaches.map((name) => caches.delete(name))) this.currentImageCacheSize = 0 // 重置计数器 } catch (error) { console.warn('清理旧的OSS图片缓存失败:', error) } } // 清除旧的本地assets缓存 async clearOldAssetCaches() { if (!('caches' in window)) return try { const cacheNames = await caches.keys() const oldAssetCaches = cacheNames.filter( (name) => name.startsWith('likei-assets-v') && !name.includes(`v${this.currentImageVersion}`), // 排除当前版本的缓存 ) console.log('正在清除旧的本地assets缓存:', oldAssetCaches) await Promise.all(oldAssetCaches.map((name) => caches.delete(name))) this.currentAssetCacheSize = 0 // 重置计数器 } catch (error) { console.warn('清理旧的本地assets缓存失败:', error) } } // 清除所有图片缓存(OSS图片、本地assets图片) async clearAllCaches() { if (!('caches' in window)) return try { const cacheNames = await caches.keys() const allCaches = cacheNames.filter( (name) => name.startsWith('likei-images-v') || name.startsWith('likei-assets-v'), ) console.log('正在清除所有图片缓存:', allCaches) await Promise.all(allCaches.map((name) => caches.delete(name))) // 重置计数器 this.currentImageCacheSize = 0 this.currentAssetCacheSize = 0 } catch (error) { console.warn('清理所有图片缓存失败:', error) } } // 判断是否为适合缓存资源 isBuildAsset(url) { try { // 规范化 URL const normalizedUrl = this.normalizeUrl(url) const containsAssets = normalizedUrl.includes('/assets/') const containsOssH5 = normalizedUrl.includes('/oss/h5/Azizi/') const startsWithOss = normalizedUrl.startsWith( 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com/h5/Azizi/', ) // 只缓存三种类型的资源 return containsAssets || containsOssH5 || startsWithOss } catch (error) { console.warn('判断资源类型时出错:', error) return false } } // 规范化 URL 以确保一致性 normalizeUrl(url) { try { // 如果是相对路径,转换为完整 URL 进行匹配 if (url.startsWith('/')) { return new URL(url, window.location.origin).toString() } // 如果已经是完整 URL,直接返回 return new URL(url).toString() } catch (e) { // 如果 URL 格式不正确,返回原 URL return url } } // 从缓存获取图片 async getCachedImage(url) { if (!('caches' in window)) return null try { // 规范化 URL const normalizedUrl = this.normalizeUrl(url) // 只从缓存获取本地资源 if (this.isBuildAsset(normalizedUrl)) { const { cache, cacheName } = await this.getCacheForUrl(normalizedUrl) // 选中对应缓存 // 尝试多种匹配方式 // 带配置的 Request 对象匹配:严格匹配具有特定 CORS 配置和凭证策略的缓存响应,确保请求模式一致 let cachedResponse = await cache.match( new Request(normalizedUrl, { mode: 'cors', credentials: 'omit', }), ) // URL 字符串精确匹配:最基础的匹配方式,查找缓存中 URL 完全一致的请求记录 if (!cachedResponse) { cachedResponse = await cache.match(normalizedUrl) } // 忽略查询参数的 URL 匹配 // 通过 urlObj.origin 和 urlObj.pathname 拼接生成 urlWithoutQuery,去除查询参数(如时间戳、token 等)后进行匹配。用于兼容同一资源因携带不同查询参数而导致 URL 不同,但实际资源内容相同的场景。 if (!cachedResponse) { const urlObj = new URL(normalizedUrl) const urlWithoutQuery = `${urlObj.origin}${urlObj.pathname}` cachedResponse = await cache.match(urlWithoutQuery) } return cachedResponse } return null } catch (error) { console.warn('获取缓存图片失败:', error) return null } } // 统一的图片加载方法 async loadImage(src) { const normalizedSrc = this.normalizeUrl(src) // 1. 检查是否有正在进行的请求 if (this.pendingRequests.has(normalizedSrc)) { return this.pendingRequests.get(normalizedSrc).promise } // 2. 检查缓存中是否存在图片 const cachedResponse = await this.getCachedImage(normalizedSrc) if (cachedResponse) { const blob = await cachedResponse.blob() return URL.createObjectURL(blob) } // 3. 请求和缓存都没有 // (1)创建新的请求并记录到 pendingRequests const loadPromise = this.createLoadPromise(normalizedSrc) this.pendingRequests.set(normalizedSrc, { promise: loadPromise, retries: this.maxRetries, // 记录重试次数 }) // (2)执行请求 try { const result = await loadPromise return result } finally { // 无论成功或失败,都要清理 pendingRequests this.pendingRequests.delete(normalizedSrc) } } // 上层请求调度器:确保所有请求统一管理 async scheduleRequest(src) { const normalizedSrc = this.normalizeUrl(src) // 如果已经在调度器中,则直接返回 if (this.requestScheduler.has(normalizedSrc)) { const result = this.requestScheduler.get(normalizedSrc) if (result instanceof Promise) { try { return await result } catch (error) { // 如果是 CORS 错误,直接返回原始 URL if (error.message.includes('CORS') || error.name === 'TypeError') { console.warn(`CORS 错误,跳过重试: ${src}`) return src } throw error // 其他错误继续抛出 } } return result } // 否则创建新请求并加入调度器 const requestPromise = this.loadImage(src) this.requestScheduler.set(normalizedSrc, requestPromise) // 不记录重试次数,只存 Promise try { return await requestPromise } finally { this.requestScheduler.delete(normalizedSrc) // 清理调度器中的记录 } } // 创建加载 Promise(含重试逻辑) async createLoadPromise(src) { // 冗余步骤(作为防御性编程) // const normalizedSrc = this.normalizeUrl(src) // // 新增:检查是否已有相同请求正在进行 // if (this.pendingRequests.has(normalizedSrc)) { // console.warn(`请求已存在,跳过重复加载: ${src}`) // return this.pendingRequests.get(normalizedSrc).promise // } const attemptLoad = async (retriesLeft) => { try { const response = await fetch(src, { mode: 'cors', credentials: 'omit', cache: 'default', }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } if (this.isBuildAsset(src)) { const { cache, cacheName } = await this.getCacheForUrl(src) const clonedResponse = response.clone() // 克隆响应用于缓存 await cache.put(src, clonedResponse) // 写入缓存 // 获取资源大小并更新缓存大小 const contentLength = response.headers.get('content-length') if (contentLength) { const size = parseInt(contentLength, 10) this._updateCacheSize(cacheName, size) // 更新缓存大小 } // 异步检查缓存限制 // ✅ 修复:包装为 Promise 确保错误捕获 // 等价于enforceCacheLimits.call(this, cache, cacheName) Promise.resolve(this.enforceCacheLimitsDebounced(cache, cacheName)).catch((error) => { console.warn('检查缓存限制时出错:', error) }) } const blob = await response.blob() return URL.createObjectURL(blob) } catch (error) { // 如果是 CORS 错误,直接终止重试 if (error.message.includes('CORS') || error.name === 'TypeError') { console.warn(`CORS 错误,终止重试: ${src}`) throw new Error(`CORS blocked: ${src}`) } // 其他错误继续重试 if (retriesLeft > 0) { console.warn(`图片加载失败,剩余重试次数: ${retriesLeft}`, src, error) return attemptLoad(retriesLeft - 1) } else { throw new Error(`图片加载失败且无重试机会: ${src}`) } } } return attemptLoad(this.maxRetries) } } // 导出单例 export const imageCacheManager = new ImageCacheManager()