mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 05:49:54 +00:00
This pull request improves the lazy loading behavior and caching
strategy for images in the `LazyImage.vue` component. The most
significant changes are focused on optimizing image rendering and
resource management, as well as improving code clarity.
**Lazy loading behavior improvements:**
* Changed the `<img>` element to render only when `cachedSrc` is
available, ensuring that images are not displayed before they are ready.
* Updated watchers in `LazyImage.vue` to use clearer variable names
(`shouldLoadVal` instead of `shouldLoad`) for better readability and
maintainability.
[[1]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L80-R81)
[[2]](diffhunk://#diff-3a1bfa7eb8cb26b04bea73f7b4b4e3c01e9d20a7eba6c3738fb47f96da1a7c95L96-R96)
**Caching strategy enhancement:**
* Modified the `fetch` call in `mediaCacheService.ts` to use `{ cache:
'force-cache' }`, which leverages the browser's cache more aggressively
when loading media, potentially improving performance and reducing
network requests.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5626-LazyImage-on-Safari-2716d73d365081eeb1d3c2a96be4d408)
by [Unito](https://www.unito.io)
227 lines
5.8 KiB
TypeScript
227 lines
5.8 KiB
TypeScript
import { reactive } from 'vue'
|
|
|
|
interface CachedMedia {
|
|
src: string
|
|
blob?: Blob
|
|
objectUrl?: string
|
|
error?: boolean
|
|
isLoading: boolean
|
|
lastAccessed: number
|
|
}
|
|
|
|
interface MediaCacheOptions {
|
|
maxSize?: number
|
|
maxAge?: number // in milliseconds
|
|
preloadDistance?: number // pixels from viewport
|
|
}
|
|
|
|
class MediaCacheService {
|
|
public cache = reactive(new Map<string, CachedMedia>())
|
|
private readonly maxSize: number
|
|
private readonly maxAge: number
|
|
private cleanupInterval: number | null = null
|
|
private urlRefCount = new Map<string, number>()
|
|
|
|
constructor(options: MediaCacheOptions = {}) {
|
|
this.maxSize = options.maxSize ?? 100
|
|
this.maxAge = options.maxAge ?? 30 * 60 * 1000 // 30 minutes
|
|
|
|
// Start cleanup interval
|
|
this.startCleanupInterval()
|
|
}
|
|
|
|
private startCleanupInterval() {
|
|
// Clean up every 5 minutes
|
|
this.cleanupInterval = window.setInterval(
|
|
() => {
|
|
this.cleanup()
|
|
},
|
|
5 * 60 * 1000
|
|
)
|
|
}
|
|
|
|
private cleanup() {
|
|
const now = Date.now()
|
|
const keysToDelete: string[] = []
|
|
|
|
// Find expired entries
|
|
for (const [key, entry] of Array.from(this.cache.entries())) {
|
|
if (now - entry.lastAccessed > this.maxAge) {
|
|
// Only revoke object URL if no components are using it
|
|
if (entry.objectUrl) {
|
|
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
|
|
if (refCount === 0) {
|
|
URL.revokeObjectURL(entry.objectUrl)
|
|
this.urlRefCount.delete(entry.objectUrl)
|
|
keysToDelete.push(key)
|
|
}
|
|
// Don't delete cache entry if URL is still in use
|
|
} else {
|
|
keysToDelete.push(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove expired entries
|
|
keysToDelete.forEach((key) => this.cache.delete(key))
|
|
|
|
// If still over size limit, remove oldest entries that aren't in use
|
|
if (this.cache.size > this.maxSize) {
|
|
const entries = Array.from(this.cache.entries())
|
|
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
|
|
|
|
let removedCount = 0
|
|
const targetRemoveCount = this.cache.size - this.maxSize
|
|
|
|
for (const [key, entry] of entries) {
|
|
if (removedCount >= targetRemoveCount) break
|
|
|
|
if (entry.objectUrl) {
|
|
const refCount = this.urlRefCount.get(entry.objectUrl) || 0
|
|
if (refCount === 0) {
|
|
URL.revokeObjectURL(entry.objectUrl)
|
|
this.urlRefCount.delete(entry.objectUrl)
|
|
this.cache.delete(key)
|
|
removedCount++
|
|
}
|
|
} else {
|
|
this.cache.delete(key)
|
|
removedCount++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async getCachedMedia(src: string): Promise<CachedMedia> {
|
|
let entry = this.cache.get(src)
|
|
|
|
if (entry) {
|
|
// Update last accessed time
|
|
entry.lastAccessed = Date.now()
|
|
return entry
|
|
}
|
|
|
|
// Create new entry
|
|
entry = {
|
|
src,
|
|
isLoading: true,
|
|
lastAccessed: Date.now()
|
|
}
|
|
|
|
// Update cache with loading entry
|
|
this.cache.set(src, entry)
|
|
|
|
try {
|
|
// Fetch the media
|
|
const response = await fetch(src, { cache: 'force-cache' })
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch: ${response.status}`)
|
|
}
|
|
|
|
const blob = await response.blob()
|
|
const objectUrl = URL.createObjectURL(blob)
|
|
|
|
// Update entry with successful result
|
|
const updatedEntry: CachedMedia = {
|
|
src,
|
|
blob,
|
|
objectUrl,
|
|
isLoading: false,
|
|
lastAccessed: Date.now()
|
|
}
|
|
|
|
this.cache.set(src, updatedEntry)
|
|
return updatedEntry
|
|
} catch (error) {
|
|
console.warn('Failed to cache media:', src, error)
|
|
|
|
// Update entry with error
|
|
const errorEntry: CachedMedia = {
|
|
src,
|
|
error: true,
|
|
isLoading: false,
|
|
lastAccessed: Date.now()
|
|
}
|
|
|
|
this.cache.set(src, errorEntry)
|
|
return errorEntry
|
|
}
|
|
}
|
|
|
|
acquireUrl(src: string): string | undefined {
|
|
const entry = this.cache.get(src)
|
|
if (entry?.objectUrl) {
|
|
const currentCount = this.urlRefCount.get(entry.objectUrl) || 0
|
|
this.urlRefCount.set(entry.objectUrl, currentCount + 1)
|
|
return entry.objectUrl
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
releaseUrl(src: string): void {
|
|
const entry = this.cache.get(src)
|
|
if (entry?.objectUrl) {
|
|
const count = (this.urlRefCount.get(entry.objectUrl) || 1) - 1
|
|
if (count <= 0) {
|
|
URL.revokeObjectURL(entry.objectUrl)
|
|
this.urlRefCount.delete(entry.objectUrl)
|
|
// Remove from cache as well
|
|
this.cache.delete(src)
|
|
} else {
|
|
this.urlRefCount.set(entry.objectUrl, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
clearCache() {
|
|
// Revoke all object URLs
|
|
for (const entry of Array.from(this.cache.values())) {
|
|
if (entry.objectUrl) {
|
|
URL.revokeObjectURL(entry.objectUrl)
|
|
}
|
|
}
|
|
this.cache.clear()
|
|
this.urlRefCount.clear()
|
|
}
|
|
|
|
destroy() {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval)
|
|
this.cleanupInterval = null
|
|
}
|
|
this.clearCache()
|
|
}
|
|
}
|
|
|
|
// Global instance
|
|
let mediaCacheInstance: MediaCacheService | null = null
|
|
|
|
export function useMediaCache(options?: MediaCacheOptions) {
|
|
if (!mediaCacheInstance) {
|
|
mediaCacheInstance = new MediaCacheService(options)
|
|
}
|
|
|
|
const getCachedMedia = (src: string) =>
|
|
mediaCacheInstance!.getCachedMedia(src)
|
|
const clearCache = () => mediaCacheInstance!.clearCache()
|
|
const acquireUrl = (src: string) => mediaCacheInstance!.acquireUrl(src)
|
|
const releaseUrl = (src: string) => mediaCacheInstance!.releaseUrl(src)
|
|
|
|
return {
|
|
getCachedMedia,
|
|
clearCache,
|
|
acquireUrl,
|
|
releaseUrl,
|
|
cache: mediaCacheInstance.cache
|
|
}
|
|
}
|
|
|
|
// Cleanup on page unload
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('beforeunload', () => {
|
|
if (mediaCacheInstance) {
|
|
mediaCacheInstance.destroy()
|
|
}
|
|
})
|
|
}
|