perf: 优化图片消息后台加载与解密缓存

- 缩略图优先展示并在后台准备原图
- 增加图片请求去重与受控并发队列
- 缓存图片路径、账号目录和解密结果
This commit is contained in:
Wxw-Gu
2026-07-28 16:11:59 +08:00
parent 49684f3365
commit d0ae9e6019
6 changed files with 400 additions and 107 deletions
+54 -72
View File
@@ -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
+167
View File
@@ -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
}