mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
perf: 优化图片消息后台加载与解密缓存
- 缩略图优先展示并在后台准备原图 - 增加图片请求去重与受控并发队列 - 缓存图片路径、账号目录和解密结果
This commit is contained in:
@@ -1,44 +1,6 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
type CachedImage = { data: string; isThumbnail: boolean }
|
||||
|
||||
const MAX_IMAGE_CACHE_ENTRIES = 80
|
||||
const imageDataUrlCache = new Map<string, CachedImage>()
|
||||
|
||||
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
}
|
||||
|
||||
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
const cached = imageDataUrlCache.get(key)
|
||||
if (cached) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function cacheImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
cached: CachedImage
|
||||
): void {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
}
|
||||
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
|
||||
const oldestKey = imageDataUrlCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
imageDataUrlCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
import { getCachedLoadedImage, requestImage } from './image-loader'
|
||||
|
||||
interface ImageBubbleProps {
|
||||
imageMd5?: string
|
||||
@@ -53,11 +15,12 @@ export function ImageBubble({
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
isThumb = false,
|
||||
fallbackUrl,
|
||||
onImageClick
|
||||
}: ImageBubbleProps): JSX.Element {
|
||||
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
||||
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
|
||||
preferThumbnail: true
|
||||
})
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
@@ -65,6 +28,26 @@ export function ImageBubble({
|
||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const backgroundUpgradeRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const upgradeOriginalInBackground = useCallback(() => {
|
||||
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
|
||||
backgroundUpgradeRef.current = true
|
||||
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
|
||||
.then((original) => {
|
||||
if (!mountedRef.current) return
|
||||
setImageUrl(original.data)
|
||||
setIsThumbnail(false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [imageDatName, imageMd5, isThumbnail, sessionId])
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (imageUrl || loading) return
|
||||
@@ -81,37 +64,42 @@ export function ImageBubble({
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
} else {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(result.error || '加载图片失败')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
0
|
||||
)
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
if (result.isThumbnail) upgradeOriginalInBackground()
|
||||
} catch (error) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError('加载图片失败')
|
||||
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||
}, [
|
||||
fallbackUrl,
|
||||
imageDatName,
|
||||
imageMd5,
|
||||
imageUrl,
|
||||
loading,
|
||||
sessionId,
|
||||
upgradeOriginalInBackground
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
@@ -154,17 +142,11 @@ export function ImageBubble({
|
||||
|
||||
setUpgrading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
|
||||
if (result.data.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
onImageClick?.(result.data)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
export type LoadedImage = {
|
||||
data: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
export type ImageLoadOptions = {
|
||||
force?: boolean
|
||||
preferThumbnail?: boolean
|
||||
}
|
||||
|
||||
type QueueItem = {
|
||||
priority: number
|
||||
run: () => Promise<LoadedImage>
|
||||
resolve: (value: LoadedImage) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_IMAGE_LOADS = 3
|
||||
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
const imageCache = new Map<string, LoadedImage>()
|
||||
const imageCacheSizes = new Map<string, number>()
|
||||
const imageRequests = new Map<string, Promise<LoadedImage>>()
|
||||
const imageQueue: QueueItem[] = []
|
||||
let activeImageLoads = 0
|
||||
let imageCacheBytes = 0
|
||||
|
||||
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
}
|
||||
|
||||
function cacheMode(options: ImageLoadOptions): string {
|
||||
if (options.force) return 'original'
|
||||
if (options.preferThumbnail) return 'thumbnail'
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function cacheKeys(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions
|
||||
): string[] {
|
||||
return imageIdentityKeys(imageMd5, imageDatName).map(
|
||||
(identity) => `${identity}:${cacheMode(options)}`
|
||||
)
|
||||
}
|
||||
|
||||
function getCachedImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions
|
||||
): LoadedImage | undefined {
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
for (const key of keys) {
|
||||
const cached = imageCache.get(key)
|
||||
if (!cached) continue
|
||||
imageCache.delete(key)
|
||||
imageCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
if (!options.force && options.preferThumbnail) {
|
||||
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
|
||||
const fallbackKey = `${identity}:auto`
|
||||
const cached = imageCache.get(fallbackKey)
|
||||
if (cached) return cached
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function cacheImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions,
|
||||
image: LoadedImage
|
||||
): void {
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
const size = image.data.length * 2
|
||||
for (const key of keys) {
|
||||
const previousSize = imageCacheSizes.get(key) || 0
|
||||
imageCacheBytes -= previousSize
|
||||
imageCache.delete(key)
|
||||
imageCacheSizes.delete(key)
|
||||
imageCache.set(key, image)
|
||||
imageCacheSizes.set(key, size)
|
||||
imageCacheBytes += size
|
||||
}
|
||||
|
||||
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
|
||||
const oldestKey = imageCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
imageCache.delete(oldestKey)
|
||||
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
|
||||
imageCacheSizes.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function pumpImageQueue(): void {
|
||||
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
|
||||
imageQueue.sort((left, right) => left.priority - right.priority)
|
||||
const item = imageQueue.shift()
|
||||
if (!item) return
|
||||
activeImageLoads += 1
|
||||
void item
|
||||
.run()
|
||||
.then(item.resolve, item.reject)
|
||||
.finally(() => {
|
||||
activeImageLoads -= 1
|
||||
pumpImageQueue()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedLoadedImage(
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
options: ImageLoadOptions = {}
|
||||
): LoadedImage | undefined {
|
||||
return getCachedImage(imageMd5, imageDatName, options)
|
||||
}
|
||||
|
||||
export function requestImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
options: ImageLoadOptions = {},
|
||||
priority = 0
|
||||
): Promise<LoadedImage> {
|
||||
const cached = getCachedImage(imageMd5, imageDatName, options)
|
||||
if (cached) return Promise.resolve(cached)
|
||||
|
||||
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
|
||||
if (!identity) return Promise.reject(new Error('缺少图片标识'))
|
||||
const requestKey = `${identity}:${cacheMode(options)}`
|
||||
const existingRequest = imageRequests.get(requestKey)
|
||||
if (existingRequest) return existingRequest
|
||||
|
||||
const request = new Promise<LoadedImage>((resolve, reject) => {
|
||||
imageQueue.push({
|
||||
priority,
|
||||
resolve,
|
||||
reject,
|
||||
run: async () => {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) {
|
||||
throw new Error(result.error || '加载图片失败')
|
||||
}
|
||||
const loadedImage = {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
}
|
||||
cacheImage(imageMd5, imageDatName, options, loadedImage)
|
||||
return loadedImage
|
||||
}
|
||||
})
|
||||
pumpImageQueue()
|
||||
})
|
||||
imageRequests.set(requestKey, request)
|
||||
void request.then(
|
||||
() => imageRequests.delete(requestKey),
|
||||
() => imageRequests.delete(requestKey)
|
||||
)
|
||||
return request
|
||||
}
|
||||
Reference in New Issue
Block a user