// src/utils/imageCacheManager.js import { IMAGE_CACHE_VERSION } from '@/config/imageCacheVersion' 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 // 最大缓存数量(100张) this.maxCacheSize = 200 * 1024 * 1024 // 最大缓存大小(100MB) this.pendingRequests = new Map() // 请求去重的关键 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') || normalizedUrl.includes('/oss/h5/') ) { cacheName = this.imageCacheName } else if (normalizedUrl.includes('/assets/')) { cacheName = this.assetCacheName } else { // 其他外部链接放入通用图片缓存 cacheName = this.imageCacheName } try { return await caches.open(cacheName) } catch (error) { console.error(`打开缓存失败: ${cacheName}`, error) throw error } } // 选择性清除缓存 async clearCacheSelective(clearType = 'images') { if (!('caches' in window)) return try { const cacheNames = await caches.keys() let cachesToClear = [] switch (clearType) { case 'images': cachesToClear = cacheNames.filter((name) => name.startsWith('likei-images-v')) break case 'assets': cachesToClear = cacheNames.filter((name) => name.startsWith('likei-assets-v')) break case 'all': // 清除所有相关缓存(除了动态内容) cachesToClear = cacheNames.filter( (name) => name.startsWith('likei-images-v') || name.startsWith('likei-assets-v'), ) break default: // 默认只清除图片缓存 cachesToClear = cacheNames.filter((name) => name.startsWith('likei-images-v')) } // 等待所有清除操作完成 await Promise.all(cachesToClear.map((name) => caches.delete(name))) } catch (error) { console.warn('清理缓存失败:', 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 } } // 检查并清理超出限制的缓存 async enforceCacheLimits(cache) { try { const keys = await cache.keys() // 检查数量限制 if (keys.length <= this.maxCacheItems) { // 快速估算大小检查(基于headers) let estimatedTotalSize = 0 let needDetailedCheck = false // 计算缓存大小 for (const key of keys) { const response = await cache.match(key) if (response) { const contentLength = response.headers.get('content-length') if (contentLength) { estimatedTotalSize += parseInt(contentLength, 10) } else { // 如果有任何一项没有content-length头,需要详细检查 needDetailedCheck = true break } } } // 检查大小限制 // 无需检查,亦或是缓存数量和大小都符合要求,直接返回 if (!needDetailedCheck && estimatedTotalSize <= this.maxCacheSize) { return } } // 缓存项超出限制,需要清理 // 获取所有缓存项及其大小信息 const cacheEntries = [] for (const key of keys) { try { const response = await cache.match(key) if (response) { const blob = await response.blob() cacheEntries.push({ key, size: blob.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) { // 按照最旧的顺序排序(keys已经是按顺序的) // 逐个删除直到满足两个条件 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) { 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))) } 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))) } 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))) } 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/') const startsWithOss = normalizedUrl.startsWith( 'https://tkm-likei.oss-ap-southeast-1.aliyuncs.com/h5/', ) // 只缓存三种类型的资源 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 cacheImage(url) { if (!('caches' in window) || !url) return try { if (this.isBuildAsset(url)) { const cache = await this.getCacheForUrl(url) const cachedResponse = await cache.match(url) if (!cachedResponse) { const response = await fetch(url, { mode: 'cors', credentials: 'omit', cache: 'no-store', // 强制不使用 HTTP 缓存 headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache', Expires: '0', }, }) if (response.ok) { await cache.put(url, response.clone()) // 添加新图片后立即检查并强制执行限制 await this.enforceCacheLimits(cache) } } } } catch (error) { console.warn(`缓存图片失败 ${url}:`, error) } } // 获取缓存的图片 Blob URL(如果是缓存的图片会返回 blob URL,否则返回原始 URL) async getCacheImageUrl(url) { if (!('caches' in window)) return url try { // 规范化 URL const normalizedUrl = this.normalizeUrl(url) // 目标符合缓存条件 if (this.isBuildAsset(normalizedUrl)) { const cache = await this.getCacheForUrl(normalizedUrl) // 尝试多种匹配方式以确保找到缓存的图片 // 1. 首先尝试使用 Request 对象进行精确匹配 const request = new Request(normalizedUrl, { mode: 'cors', credentials: 'omit', }) let cachedResponse = await cache.match(request) // 2. 如果精确匹配失败,尝试直接使用 URL 字符串匹配 if (!cachedResponse) { cachedResponse = await cache.match(normalizedUrl) } // 3. 如果还是没找到,尝试使用 URL 的 origin + pathname(去掉查询参数) if (!cachedResponse) { const urlObj = new URL(normalizedUrl) const urlWithoutQuery = `${urlObj.origin}${urlObj.pathname}` cachedResponse = await cache.match(urlWithoutQuery) } // 4. 尝试匹配去掉协议的 URL if (!cachedResponse) { const urlWithoutProtocol = normalizedUrl.replace(/^https?:\/\//, '') cachedResponse = await cache.match(urlWithoutProtocol) } // 经过上面流程后,匹配成功 if (cachedResponse) { const blob = await cachedResponse.blob() // 获取命中缓存对象的blob return URL.createObjectURL(blob) // 返回创建的 Blob URL } // 经过上面流程后,缓存依旧未命中 else { try { await this.cacheImage(normalizedUrl) // 触发预缓存 } catch (cacheError) { console.warn(`预缓存失败: ${cacheError}`) } } } return url // 不符合缓存条件,直接返回原始 URL } catch (error) { console.warn('获取缓存图片 URL 失败:', error) return url } } // 从缓存获取图片 async getCachedImage(url) { if (!('caches' in window)) return null try { // 规范化 URL const normalizedUrl = this.normalizeUrl(url) // 只从缓存获取本地资源 if (this.isBuildAsset(normalizedUrl)) { const cache = await this.getCacheForUrl(normalizedUrl) // 选中对应缓存 // 尝试多种匹配方式 let cachedResponse = await cache.match( new Request(normalizedUrl, { mode: 'cors', credentials: 'omit', }), ) if (!cachedResponse) { cachedResponse = await cache.match(normalizedUrl) } 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) { // 规范化 URL const normalizedSrc = this.normalizeUrl(src) // 1. 请求去重:检查是否有相同请求正在进行 if (this.pendingRequests.has(normalizedSrc)) { return this.pendingRequests.get(normalizedSrc) // 返回对应的Promise,避免重复请求 } // 2. 请求列表中不存在,则创建新的加载Promise const loadPromise = new Promise(async (resolve, reject) => { try { const cachedResponse = await this.getCachedImage(normalizedSrc) // 3. 获取对应的缓存图片 // 4. 缓存命中,处理图片 if (cachedResponse) { const blob = await cachedResponse.blob() //将缓存响应转换为 Blob 对象 const imageUrl = URL.createObjectURL(blob) //生成临时的 Blob URL 供 Image 对象使用 const img = new Image() // 5. 创建Image对象并设置src // 设置 onload 和 onerror 回调 img.onload = () => { URL.revokeObjectURL(imageUrl) // 清理临时URL resolve(img) } img.onerror = () => { URL.revokeObjectURL(imageUrl) // 8. 清理临时URL this.loadImageFromNetwork(normalizedSrc, resolve, reject) // 9. 回退到网络加载 } img.src = imageUrl // 10. 开始加载 return } this.loadImageFromNetwork(normalizedSrc, resolve, reject) // 11. 缓存中没有或不是本地资源,从网络加载 } catch (error) { this.loadImageFromNetwork(normalizedSrc, resolve, reject) // 12. 网络加载失败,回退到网络加载 } }) // 12. 记录正在进行的请求 this.pendingRequests.set(normalizedSrc, loadPromise) try { const result = await loadPromise // 等待加载完成 return result } finally { this.pendingRequests.delete(normalizedSrc) // 13. 清理记录 } } // 从网络加载图片 async loadImageFromNetwork(src, resolve, reject) { try { // 规范化 URL const normalizedSrc = this.normalizeUrl(src) // 统一处理所有资源,不再对OSS做特殊处理 const response = await fetch(normalizedSrc, { mode: 'cors', credentials: 'omit', cache: 'no-store', // 强制不使用 HTTP 缓存 }) // 加载完成 if (response.ok) { // 克隆响应,一份用于缓存,一份用于图片加载 const isAsset = this.isBuildAsset(normalizedSrc) // 检查是否为适合缓存的资源 const responseForCache = isAsset ? response.clone() : null //复制适合缓存的资源 const responseForImage = response // 原始响应用于图片加载 // 适合缓存 if (isAsset) { try { const cache = await this.getCacheForUrl(normalizedSrc) // 获取对应的缓存 await cache.put(normalizedSrc, responseForCache) // 缓存克隆的响应 } catch (cacheError) { console.warn('缓存图片时出错:', cacheError) } } // 创建Image对象并设置src const img = new Image() img.onload = () => { resolve(img) } img.onerror = (error) => { reject(error) } img.src = URL.createObjectURL(await responseForImage.blob()) // 使用原始响应 } // 加载失败 else { reject(new Error(`HTTP ${response.status}`)) } } catch (error) { reject(error) } } // 获取当前版本的缓存名称 getCacheNameByUrl(url) { const normalizedUrl = this.normalizeUrl(url) if ( normalizedUrl.startsWith('https://tkm-likei.oss-ap-southeast-1.aliyuncs.com/h5') || normalizedUrl.includes('/oss/h5/') ) { return this.imageCacheName } else if (normalizedUrl.includes('/assets/')) { return this.assetCacheName } else { return this.imageCacheName } } } // 导出单例 export const imageCacheManager = new ImageCacheManager()