503 lines
17 KiB
JavaScript
503 lines
17 KiB
JavaScript
// 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()
|