aslan-h5/src/utils/image/imageCacheManager.js

503 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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()