精通混合移动应用中的图像缓存与懒加载

发布: (2026年2月16日 GMT+8 14:19)
7 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您希望翻译的正文内容,我将按照要求保留源链接并翻译文本。

默认 WebView 缓存的问题

当你仅依赖标准的 <img> 标签时,资源管理会交给底层的移动浏览器引擎(iOS 上的 WKWebView,Android 上的 Chromium)来处理。这会带来几个问题:

  • 激进的缓存清理 – WebView 会定期清除缓存以释放系统内存,导致用户必须重新下载刚才看到的头像和缩略图。
  • 重复请求 – 同一张图片在屏幕上出现多次时,浏览器可能会发起多个并发的网络请求。
  • 内存未管理 – 获取大尺寸图片而不进行清理,可能会因内存限制导致应用崩溃。

解决方案:自定义 LRU Blob 缓存

绕过 WebView 限制的最有效方法是拦截图片获取过程,将图片下载为 Blob 数据,并以 Object URL 的形式本地存储在内存中。提供的 ImageCacheService 使用最近最少使用(LRU)缓存策略优雅地实现了这一点。

1. 全局缓存服务

该架构的核心是一个在根级别提供的 Angular 服务。

  • 基于 Map 的存储 – 使用标准的 JavaScript Map 保存原始源 URL 与生成的本地 Object URL 的映射。
  • LRU 提升 – 当请求图片时,服务会检查缓存中是否已有该条目。如果有,则先删除再重新插入该条目,使其移动到 Map 的“末尾”(标记为最近使用)。
  • 内存管理 – 缓存设有硬上限(MAX_CACHE_SIZE = 100)。当达到上限时,服务会识别最旧的条目(Map 中的第一个键),调用 URL.revokeObjectURL() 释放浏览器内存,并删除该条目。
@Injectable({ providedIn: 'root' })
export class ImageCacheService {
  private readonly MAX_CACHE_SIZE = 100;
  private cache = new Map(); // original URL → object URL
  private pending = new Map(); // ongoing fetches

  async get(url: string): Promise {
    if (this.cache.has(url)) {
      const objUrl = this.cache.get(url)!;
      // LRU bump
      this.cache.delete(url);
      this.cache.set(url, objUrl);
      return objUrl;
    }

    if (this.pending.has(url)) {
      return this.pending.get(url)!;
    }

    const fetchPromise = this.fetchAndCache(url);
    this.pending.set(url, fetchPromise);
    const result = await fetchPromise;
    this.pending.delete(url);
    return result;
  }

  private async fetchAndCache(url: string): Promise {
    const response = await fetch(url);
    const blob = await response.blob();
    const objUrl = URL.createObjectURL(blob);
    this.addToCache(url, objUrl);
    return objUrl;
  }

  private addToCache(url: string, objUrl: string) {
    if (this.cache.size >= this.MAX_CACHE_SIZE) {
      const oldestKey = this.cache.keys().next().value;
      URL.revokeObjectURL(this.cache.get(oldestKey)!);
      this.cache.delete(oldestKey);
    }
    this.cache.set(url, objUrl);
  }
}

2. 防止重复请求

在列表或信息流中,同一用户头像可能会出现多次。服务维护一个 pending Map 来跟踪正在进行的 Promise 下载:

  • 如果针对特定 URL 的 HTTP 请求已经在进行中,后续请求会挂载到已有的 promise 上,而不是再次发起网络请求。
  • 当 fetch 完成(或失败)后,URL 会从 pending map 中移除。

UI 层:智能懒加载指令

在缓存逻辑被抽离到服务后,UI 需要一种方式来无缝使用它。独立的 LazyLoadDirective 作用于带有 appLazyLoad 属性的任意 <img> 标签。

1. 结构与占位符

指令初始化时,会优化 DOM 结构并设置加载状态:

  • Picture 包装器 – 如果图片尚未位于 <picture> 标签内,指令会以编程方式将 <img> 包裹在 <picture> 中,并添加 lazy-picture-wrapper 类,以便更容易进行 CSS 定位。
  • 透明 GIF – 在真实图片被获取之前,指令会向 src 属性注入一个轻量级的透明 base64 GIF (data:image/gif;base64,...)。
  • 加载状态 – 会应用 img-loading CSS 类,可用于骨架动画或加载指示器。
@Directive({
  selector: '[appLazyLoad]',
  standalone: true,
})
export class LazyLoadDirective implements OnInit, OnDestroy {
  @Input('appLazyLoad') src!: string;
  private abortController?: AbortController;
  private unlisteners: (() => void)[] = [];

  constructor(
    private el: ElementRef,
    private cache: ImageCacheService,
    private renderer: Renderer2
  ) {}

  ngOnInit() {
    this.setupWrapper();
    this.loadImage();
  }

  ngOnChanges() {
    this.loadImage();
  }

  ngOnDestroy() {
    this.abortController?.abort();
    this.unlisteners.forEach((un) => un());
  }

  private setupWrapper() {
    const img = this.el.nativeElement;
    if (!img.parentElement?.matches('picture')) {
      const picture = this.renderer.createElement('picture');
      this.renderer.addClass(picture, 'lazy-picture-wrapper');
      const parent = img.parentNode;
      this.renderer.insertBefore(parent, picture, img);
      this.renderer.removeChild(parent, img);
      this.renderer.appendChild(picture, img);
    }
    img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
    this.renderer.addClass(img, 'img-loading');
  }

  private async loadImage() {
    this.abortController?.abort();
    this.abortController = new AbortController();
    const signal = this.abortController.signal;

    // Skip cache for data URIs
    if (this.src.startsWith('data:')) {
      this.applySrc(this.src);
      return;
    }

    try {
      const objectUrl = await this.cache.get(this.src);
      if (signal.aborted) return;
      this.applySrc(objectUrl);
    } catch (e) {
      // handle error (optional)
    }
  }

  private applySrc(url: string) {
    const img = this.el.nativeElement;
    img.src = url;
    this.renderer.removeClass(img, 'img-loading');
  }
}

2. 优雅的取消

移动端用户滚动速度快。如果用户在图片下载完成前已经滚动过去,我们不应浪费处理能力去渲染它。

  • AbortControllers – 指令为每次加载尝试创建一个新的 AbortController
  • 生命周期清理 – 当 src 输入变化或组件销毁时,会调用控制器的 abort() 方法。
  • 安全检查 – 在全局缓存解析出 Blob URL 后,指令会检查 signal.aborted。如果已取消,则停止 DOM 更新,防止旧图片在新图片上渲染导致的竞争条件。

3. 边缘情况

一个健壮的指令必须处理不需要获取的情况:

  • Data URI – 如果提供的图片源已经是 base64 字符串(startsWith('data:')),指令会跳过缓存,直接应用该源,并立即移除加载类。
  • 事件监听器 – 监听原生的 loaderror 事件,以确保无论成功还是失败,img-loading 类都能可靠地被移除。这些监听器存放在 unlisteners 数组中,并在销毁时清理,以防止内存泄漏。

通过将缓存逻辑(“大脑”)与 DOM 操作(UI 层)分离,这种架构为用户提供了高性能、原生感受的图片体验。

混合应用程序。

0 浏览
Back to Blog

相关文章

阅读更多 »