590 lines
19 KiB
JavaScript
590 lines
19 KiB
JavaScript
// 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()
|