feat(图片缓存): 限制缓存缓存的大小和数量,超出时删去最旧的图片,直至满足条件

This commit is contained in:
hzj 2026-02-27 10:57:53 +08:00
parent adf381fba6
commit 591057b331

View File

@ -13,6 +13,8 @@ class ImageCacheManager {
this.maxCacheItems = 200 // 最大缓存数量100张
this.maxCacheSize = 200 * 1024 * 1024 // 最大缓存大小100MB
this.currentImageCacheSize = 0 // 当前image缓存大小字节
this.currentAssetCacheSize = 0 // 当前asset缓存大小字节
this.pendingRequests = new Map() // { url: { promise, retries } }
this.maxRetries = 3 // 最大重试次数
@ -57,46 +59,14 @@ class ImageCacheManager {
}
try {
return await caches.open(cacheName)
const cache = await caches.open(cacheName)
return { cache, 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()
}
@ -161,75 +131,66 @@ class ImageCacheManager {
}
}
// 更新指定缓存的大小
_updateCacheSize(cacheName, deltaSize) {
if (cacheName === this.imageCacheName) {
this.currentImageCacheSize += deltaSize
} else if (cacheName === this.assetCacheName) {
this.currentAssetCacheSize += deltaSize
}
}
// 检查并清理超出限制的缓存
async enforceCacheLimits(cache) {
async enforceCacheLimits(cache, cacheName) {
try {
const keys = await cache.keys()
// 快速判断:根据缓存名称选择对应的大小属性
let currentCacheSize
if (cacheName === this.imageCacheName) {
currentCacheSize = this.currentImageCacheSize
} else if (cacheName === this.assetCacheName) {
currentCacheSize = this.currentAssetCacheSize
} else {
console.warn(`未知缓存名称: ${cacheName}`)
return
}
// 检查数量限制
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) {
// 如果当前缓存大小未超过限制,直接返回
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) {
const blob = await response.blob()
cacheEntries.push({
key,
size: blob.size,
response,
})
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 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 itemsToRemoveCount = Math.max(0, totalItems - this.maxCacheItems)
let sizeToRemove = Math.max(0, totalSize - this.maxCacheSize)
let itemsToDelete = []
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
}
@ -240,8 +201,16 @@ class ImageCacheManager {
}
}
// 执行删除操作
// 执行删除操作并更新缓存大小
for (const key of itemsToDelete) {
const response = await cache.match(key)
if (response) {
const contentLength = response.headers.get('content-length')
if (contentLength) {
const size = parseInt(contentLength, 10)
this._updateCacheSize(cacheName, -size) // 减少缓存大小
}
}
await cache.delete(key)
}
} catch (error) {
@ -337,98 +306,6 @@ class ImageCacheManager {
}
}
// 缓存图片
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
@ -439,7 +316,7 @@ class ImageCacheManager {
// 只从缓存获取本地资源
if (this.isBuildAsset(normalizedUrl)) {
const cache = await this.getCacheForUrl(normalizedUrl) // 选中对应缓存
const { cache, cacheName } = await this.getCacheForUrl(normalizedUrl) // 选中对应缓存
// 尝试多种匹配方式
let cachedResponse = await cache.match(
@ -542,6 +419,14 @@ class ImageCacheManager {
// 创建加载 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, {
@ -555,8 +440,21 @@ class ImageCacheManager {
}
if (this.isBuildAsset(src)) {
const cache = await this.getCacheForUrl(src)
await cache.put(src, response.clone())
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) // 更新缓存大小
}
// 异步检查缓存限制
this.enforceCacheLimits(cache, cacheName).catch((error) => {
console.warn('检查缓存限制时出错:', error)
})
}
const blob = await response.blob()
@ -580,22 +478,6 @@ class ImageCacheManager {
return attemptLoad(this.maxRetries)
}
// 获取当前版本的缓存名称
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
}
}
}
// 导出单例