524 lines
16 KiB
JavaScript
524 lines
16 KiB
JavaScript
// src/utils/imageCacheManager.js
|
||
class ImageCacheManager {
|
||
constructor() {
|
||
this.cacheVersionKey = 'likei-image-cache-version'
|
||
this.currentAppVersion = import.meta.env.VITE_APP_VERSION || '1.0.0'
|
||
|
||
this.imageCacheName = `likei-images-v${this.currentAppVersion}` // OSS图片
|
||
this.assetCacheName = `likei-assets-v${this.currentAppVersion}` // 本地assets
|
||
|
||
this.maxCacheItems = 100 // 最大缓存数量(100张)
|
||
this.maxCacheSize = 100 * 1024 * 1024 // 最大缓存大小(100MB)
|
||
|
||
this.pendingRequests = new Map() // 请求去重的关键
|
||
|
||
this.init()
|
||
}
|
||
|
||
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)
|
||
|
||
if (!cachedVersion) {
|
||
localStorage.setItem(this.cacheVersionKey, this.currentAppVersion)
|
||
return
|
||
}
|
||
|
||
if (cachedVersion !== this.currentAppVersion) {
|
||
await this.clearOldCaches()
|
||
localStorage.setItem(this.cacheVersionKey, this.currentAppVersion)
|
||
}
|
||
}
|
||
|
||
// 检查并清理超出限制的缓存
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 清除全部旧图片缓存
|
||
async clearOldCaches() {
|
||
if (!('caches' in window)) return
|
||
|
||
try {
|
||
const cacheNames = await caches.keys()
|
||
const oldCaches = cacheNames.filter(
|
||
(name) => name.startsWith('likei-images-v') && name !== this.imageCacheName
|
||
)
|
||
|
||
await Promise.all(oldCaches.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
|
||
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)
|
||
|
||
if (!cachedResponse) {
|
||
// 2. 如果精确匹配失败,尝试直接使用 URL 字符串匹配
|
||
cachedResponse = await cache.match(normalizedUrl)
|
||
}
|
||
|
||
if (!cachedResponse) {
|
||
// 3. 如果还是没找到,尝试使用 URL 的 origin + pathname(去掉查询参数)
|
||
const urlObj = new URL(normalizedUrl)
|
||
const urlWithoutQuery = `${urlObj.origin}${urlObj.pathname}`
|
||
cachedResponse = await cache.match(urlWithoutQuery)
|
||
}
|
||
|
||
if (!cachedResponse) {
|
||
// 4. 尝试匹配去掉协议的 URL
|
||
const urlWithoutProtocol = normalizedUrl.replace(/^https?:\/\//, '')
|
||
cachedResponse = await cache.match(urlWithoutProtocol)
|
||
}
|
||
|
||
if (cachedResponse) {
|
||
const blob = await cachedResponse.blob()
|
||
return URL.createObjectURL(blob)
|
||
} else {
|
||
// 缓存未命中,触发预缓存
|
||
try {
|
||
await this.cacheImage(normalizedUrl)
|
||
} catch (cacheError) {
|
||
console.warn(`预缓存失败: ${cacheError}`)
|
||
}
|
||
}
|
||
}
|
||
// 如果没有缓存,返回原始 URL
|
||
return 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)) {
|
||
// 返回现有的Promise,避免重复请求
|
||
return this.pendingRequests.get(normalizedSrc)
|
||
}
|
||
|
||
// 2. 创建新的加载Promise
|
||
const loadPromise = new Promise(async (resolve, reject) => {
|
||
try {
|
||
// 3. 尝试从缓存获取
|
||
const cachedResponse = await this.getCachedImage(normalizedSrc)
|
||
if (cachedResponse) {
|
||
// 4. 缓存命中,处理图片
|
||
const blob = await cachedResponse.blob()
|
||
const imageUrl = URL.createObjectURL(blob)
|
||
|
||
// 5. 创建Image对象并设置src
|
||
const img = new Image()
|
||
img.onload = () => {
|
||
// 6. 加载成功,清理临时URL并resolve
|
||
URL.revokeObjectURL(imageUrl)
|
||
resolve(img)
|
||
}
|
||
|
||
// 7. 关键:加载失败时的处理
|
||
img.onerror = () => {
|
||
// 8. 清理临时URL
|
||
URL.revokeObjectURL(imageUrl)
|
||
// 9. 回退到网络加载
|
||
this.loadImageFromNetwork(normalizedSrc, resolve, reject)
|
||
}
|
||
|
||
img.src = imageUrl // 10. 开始加载
|
||
return
|
||
}
|
||
|
||
// 11. 缓存中没有或不是本地资源,从网络加载
|
||
this.loadImageFromNetwork(normalizedSrc, resolve, reject)
|
||
} catch (error) {
|
||
this.loadImageFromNetwork(normalizedSrc, resolve, reject)
|
||
}
|
||
})
|
||
|
||
// 12. 记录正在进行的请求
|
||
this.pendingRequests.set(normalizedSrc, loadPromise)
|
||
|
||
try {
|
||
const result = await loadPromise
|
||
return result
|
||
} finally {
|
||
// 13. 清理记录
|
||
this.pendingRequests.delete(normalizedSrc)
|
||
}
|
||
}
|
||
|
||
// 从网络加载图片
|
||
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 {
|
||
// 使用 getCacheForUrl 确保使用正确的缓存
|
||
const cache = await this.getCacheForUrl(normalizedSrc)
|
||
await cache.put(normalizedSrc, responseForCache) // 使用克隆的响应进行缓存
|
||
} catch (cacheError) {
|
||
console.warn('缓存图片时出错:', cacheError)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// 为OSS图片创建缓存的特殊处理
|
||
async cacheImageFromImage(src, img) {
|
||
try {
|
||
// 检查浏览器是否支持缓存API以及资源是否适合缓存
|
||
if (!('caches' in window) || !this.isBuildAsset(src)) return
|
||
|
||
const cache = await this.getCacheForUrl(src)
|
||
const cachedResponse = await cache.match(src)
|
||
|
||
// 只有当图片尚未缓存时才进行缓存操作
|
||
if (!cachedResponse) {
|
||
// 创建canvas元素来处理图片
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = img.width
|
||
canvas.height = img.height
|
||
const ctx = canvas.getContext('2d')
|
||
ctx.drawImage(img, 0, 0) // 将图片绘制到canvas上
|
||
|
||
// 将canvas转换为blob对象
|
||
canvas.toBlob(async (blob) => {
|
||
if (blob) {
|
||
// 创建一个新的Response对象来模拟网络响应
|
||
const response = new Response(blob, {
|
||
headers: {
|
||
'Content-Type': 'image/png',
|
||
},
|
||
})
|
||
// 将图片存入缓存
|
||
await cache.put(src, response.clone())
|
||
}
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.warn('缓存OSS图片失败:', src, error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const imageCacheManager = new ImageCacheManager()
|