🔥 Vue3 + TypeScript 实现高性能图片懒加载v-lazyLoad指令(开箱即用)

110 阅读8分钟

🔥 Vue3 图片懒加载指令终极版:支持重试、自定义配置、TypeScript 全类型支持

在现代前端开发中,图片懒加载是提升页面加载性能的核心手段之一。原生的 loading="lazy" 虽然简单,但缺乏灵活的配置和错误重试机制。本文将分享一个生产级别的 Vue3 图片懒加载指令,基于 IntersectionObserver API 实现,支持失败重试、自定义占位图、样式控制等丰富功能,且全程使用 TypeScript 开发,类型提示完善。

在这里插入图片描述

🎯 核心特性

  • ✅ 基于 IntersectionObserver 实现,性能优异
  • ✅ 支持图片加载失败自动重试(指数退避策略)
  • ✅ 自定义占位图、错误图、加载状态样式类
  • ✅ 全 TypeScript 开发,类型定义完善
  • ✅ 支持指令参数灵活配置(字符串/对象)
  • ✅ 提供手动触发/重置加载的方法
  • ✅ 自动清理定时器和观察器,无内存泄漏
  • ✅ 支持跨域图片加载

📁 完整代码实现(优化版)

// lazyLoad.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 懒加载配置接口
 */
export interface LazyLoadOptions {
  root?: Element | Document | null          // 观察器根元素
  rootMargin?: string                       // 根元素边距
  threshold?: number | number[]             // 可见性阈值
  placeholder?: string                      // 占位图地址
  error?: string                            // 加载失败图地址
  loadingClass?: string                     // 加载中样式类
  loadedClass?: string                      // 加载完成样式类
  errorClass?: string                       // 加载失败样式类
  attempt?: number                          // 最大重试次数
  observerOptions?: IntersectionObserverInit // 观察器额外配置
  src?: string                              // 图片地址
}

/**
 * 指令绑定值类型:支持字符串(仅图片地址)或完整配置对象
 */
type LazyLoadBindingValue = string | LazyLoadOptions

/**
 * 元素加载状态枚举
 */
enum LoadStatus {
  PENDING = 'pending',   // 待加载
  LOADING = 'loading',   // 加载中
  LOADED = 'loaded',     // 加载完成
  ERROR = 'error'        // 加载失败
}

/**
 * 扩展元素属性:存储懒加载相关状态
 */
interface LazyElement extends HTMLElement {
  _lazyLoad?: {
    src: string
    options: LazyLoadOptions
    observer: IntersectionObserver | null
    status: LoadStatus
    attemptCount: number          // 已失败次数(从0开始)
    retryTimer?: number           // 重试定时器ID
    cleanup: () => void           // 清理函数
  }
}

/**
 * 默认配置:合理的默认值,兼顾通用性和易用性
 */
const DEFAULT_OPTIONS: LazyLoadOptions = {
  root: null,
  rootMargin: '0px',
  threshold: 0.1,
  // 透明占位图(最小体积)
  placeholder: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3C/svg%3E',
  // 错误占位图(带❌标识)
  error: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"%3E%3Ctext x="0.5" y="0.5" font-size="0.1" text-anchor="middle"%3E❌%3C/text%3E%3C/svg%3E',
  loadingClass: 'lazy-loading',
  loadedClass: 'lazy-loaded',
  errorClass: 'lazy-error',
  attempt: 3,  // 默认重试3次
  observerOptions: {}
}

// 全局观察器缓存:避免重复创建,提升性能
let globalObserver: IntersectionObserver | null = null
const observerCallbacks = new WeakMap<Element, () => void>()

/**
 * 创建/获取全局IntersectionObserver实例
 * @param options 懒加载配置
 * @returns 观察器实例
 */
const getObserver = (options: LazyLoadOptions): IntersectionObserver => {
  const observerOptions: IntersectionObserverInit = {
    root: options.root,
    rootMargin: options.rootMargin,
    threshold: options.threshold,
    ...options.observerOptions
  }

  if (!globalObserver) {
    globalObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const callback = observerCallbacks.get(entry.target)
          if (callback) {
            callback()
            globalObserver?.unobserve(entry.target)
            observerCallbacks.delete(entry.target)
          }
        }
      })
    }, observerOptions)
  }

  return globalObserver
}

/**
 * 核心加载逻辑:封装图片加载、重试、状态管理
 * @param el 目标元素
 * @param src 图片地址
 * @param options 配置项
 */
const loadImage = (el: LazyElement, src: string, options: LazyLoadOptions) => {
  const lazyData = el._lazyLoad
  if (!lazyData) return

  // 防止重复加载
  if (lazyData.status === LoadStatus.LOADING || lazyData.status === LoadStatus.LOADED) {
    return
  }

  // 更新状态:标记为加载中
  lazyData.status = LoadStatus.LOADING
  el.setAttribute('data-lazy-status', LoadStatus.LOADING)
  el.classList.add(options.loadingClass!)
  el.classList.remove(options.loadedClass!, options.errorClass!)

  // 创建新图片对象(每次重试创建新实例,避免缓存问题)
  const image = new Image()
  image.crossOrigin = 'anonymous'  // 支持跨域图片

  /**
   * 失败处理:指数退避重试 + 最终失败处理
   */
  const handleFail = () => {
    // 清除当前重试定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 累加失败次数
    lazyData.attemptCount += 1

    // 还有重试次数:指数退避策略(1s → 2s → 4s,最大5s)
    if (lazyData.attemptCount < options.attempt!) {
      const delay = Math.min(1000 * Math.pow(2, lazyData.attemptCount - 1), 5000)
      lazyData.retryTimer = window.setTimeout(() => {
        lazyData.status = LoadStatus.PENDING
        loadImage(el, src, options)
      }, delay) as unknown as number
    } 
    // 重试耗尽:标记失败状态
    else {
      lazyData.status = LoadStatus.ERROR
      el.setAttribute('data-lazy-status', LoadStatus.ERROR)
      el.classList.remove(options.loadingClass!)
      el.classList.add(options.errorClass!)
      
      // 设置错误图片
      if (options.error) {
        (el as HTMLImageElement).src = options.error
      }
      
      // 触发自定义错误事件
      el.dispatchEvent(new CustomEvent('lazy-error', {
        detail: { src, element: el, attempts: lazyData.attemptCount }
      }))
    }
  }

  /**
   * 成功处理:更新状态 + 替换图片
   */
  const handleSuccess = () => {
    // 清除重试定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 更新状态:标记为加载完成
    lazyData.status = LoadStatus.LOADED
    el.setAttribute('data-lazy-status', LoadStatus.LOADED)
    el.classList.remove(options.loadingClass!)
    if (el.classList) {
        el.classList.add(options.loadedClass!)
    }
    
    // 替换为目标图片
    (el as HTMLImageElement).src = src
    
    // 触发自定义成功事件
    el.dispatchEvent(new CustomEvent('lazy-loaded', {
      detail: { src, element: el }
    }))
  }

  // 绑定事件(once: true 确保只触发一次)
  image.addEventListener('load', handleSuccess, { once: true })
  image.addEventListener('error', handleFail, { once: true })

  // 开始加载(放在最后,避免事件绑定前触发)
  image.src = src
}

/**
 * 懒加载指令核心实现
 */
export const lazyLoad: ObjectDirective<LazyElement, LazyLoadBindingValue> = {
  /**
   * 指令挂载时:初始化配置 + 注册观察器
   */
  mounted(el: LazyElement, binding: DirectiveBinding<LazyLoadBindingValue>) {
    // 1. 解析配置和图片地址
    let src: string = ''
    const options: LazyLoadOptions = { ...DEFAULT_OPTIONS }

    if (typeof binding.value === 'string') {
      src = binding.value
    } else {
      Object.assign(options, binding.value)
      src = options.src || el.dataset.src || el.getAttribute('data-src') || ''
    }

    // 校验图片地址
    if (!src) {
      console.warn('[v-lazy-load] 缺少图片地址,请设置src或data-src属性')
      return
    }

    // 2. 初始化元素状态
    el.setAttribute('data-lazy-status', LoadStatus.PENDING)
    el.classList.add(options.loadingClass!)
    if (options.placeholder) {
      (el as HTMLImageElement).src = options.placeholder
    }

    // 3. 创建观察器
    const observer = getObserver(options)
    
    // 4. 定义清理函数:统一管理资源释放
    const cleanup = () => {
      observer.unobserve(el)
      observerCallbacks.delete(el)
      
      // 清理定时器
      if (el._lazyLoad?.retryTimer) {
        clearTimeout(el._lazyLoad.retryTimer)
        el._lazyLoad.retryTimer = undefined
      }

      // 清理样式和属性
      el.classList.remove(options.loadingClass!, options.loadedClass!, options.errorClass!)
      el.removeAttribute('data-lazy-status')
    }

    // 5. 保存核心状态
    el._lazyLoad = {
      src,
      options,
      observer,
      status: LoadStatus.PENDING,
      attemptCount: 0,
      retryTimer: undefined,
      cleanup
    }

    // 6. 注册观察回调
    observerCallbacks.set(el, () => loadImage(el, src, options))
    observer.observe(el)
  },

  /**
   * 指令更新时:处理图片地址变化
   */
  updated(el: LazyElement, binding: DirectiveBinding<LazyLoadBindingValue>) {
    const lazyData = el._lazyLoad
    if (!lazyData) return

    // 清理旧定时器
    if (lazyData.retryTimer) {
      clearTimeout(lazyData.retryTimer)
      lazyData.retryTimer = undefined
    }

    // 解析新地址
    let newSrc: string = ''
    if (typeof binding.value === 'string') {
      newSrc = binding.value
    } else {
      newSrc = binding.value.src || el.dataset.src || el.getAttribute('data-src') || ''
    }

    // 地址变化:重新初始化
    if (newSrc && newSrc !== lazyData.src) {
      lazyData.cleanup()
      lazyLoad.mounted(el, binding)
    }
  },

  /**
   * 指令卸载时:彻底清理资源
   */
  unmounted(el: LazyElement) {
    const lazyData = el._lazyLoad
    if (lazyData) {
      clearTimeout(lazyData.retryTimer)
      lazyData.cleanup()
      delete el._lazyLoad // 释放内存
    }
  }
}

/**
 * 手动触发图片加载(无需等待元素进入视口)
 * @param el 目标元素
 */
export const triggerLoad = (el: HTMLElement) => {
  const lazyEl = el as LazyElement
  const callback = observerCallbacks.get(lazyEl)
  if (callback) {
    callback()
    lazyEl._lazyLoad?.observer?.unobserve(lazyEl)
    observerCallbacks.delete(lazyEl)
  }
}

/**
 * 重置图片加载状态(重新开始懒加载)
 * @param el 目标元素
 */
export const resetLoad = (el: HTMLElement) => {
  const lazyEl = el as LazyElement
  const lazyData = lazyEl._lazyLoad
  
  if (lazyData) {
    // 清理旧状态
    clearTimeout(lazyData.retryTimer)
    lazyData.cleanup()
    delete lazyEl._lazyLoad
    
    // 重新注册观察器
    const observer = getObserver(lazyData.options)
    observerCallbacks.set(lazyEl, () => loadImage(lazyEl, lazyData.src, lazyData.options))
    observer.observe(lazyEl)
    
    // 重置样式和占位图
    lazyEl.setAttribute('data-lazy-status', LoadStatus.PENDING)
    lazyEl.classList.add(lazyData.options.loadingClass!)
    if (lazyData.options.placeholder) {
      (lazyEl as HTMLImageElement).src = lazyData.options.placeholder
    }
  }
}

/**
 * 全局注册懒加载指令
 * @param app Vue应用实例
 */
export const setupLazyLoadDirective = (app: App) => {
  app.directive('lazy-load', lazyLoad)
  // 挂载全局方法:方便在组件内调用
  app.config.globalProperties.$lazyLoad = {
    triggerLoad,
    resetLoad
  }
}

// TS类型扩展:增强类型提示
declare module 'vue' {
  export interface ComponentCustomProperties {
    $lazyLoad: {
      triggerLoad: typeof triggerLoad
      resetLoad: typeof resetLoad
    }
  }
}

declare global {
  interface HTMLElement {
    dataset: DOMStringMap & {
      src?: string
      lazyStatus?: string
    }
  }
}

🚀 使用指南

1. 全局注册指令

main.ts 中注册指令:

import { createApp } from 'vue'
import { setupLazyLoadDirective } from './directives/lazyLoad'
import App from './App.vue'

const app = createApp(App)
// 注册懒加载指令
setupLazyLoadDirective(app)
app.mount('#app')

2. 基础使用(字符串形式)

<template>
  <!-- 最简单的用法:直接传图片地址 -->
  <img v-lazy-load="imageUrl" alt="示例图片" />
</template>

<script setup lang="ts">
const imageUrl = 'https://example.com/your-image.jpg'
</script>

3. 高级使用(对象配置)

<template>
  <!-- 自定义配置 -->
  <img 
    v-lazy-load="{
      src: imageUrl,
      placeholder: 'https://example.com/placeholder.png',
      error: 'https://example.com/error.png',
      attempt: 5,  // 重试5次
      loadingClass: 'my-loading',
      rootMargin: '50px'
    }"
    @lazy-loaded="handleLoaded"
    @lazy-error="handleError"
    alt="高级示例"
  />
</template>

<script setup lang="ts">
const imageUrl = 'https://example.com/your-image.jpg'

// 加载成功回调
const handleLoaded = (e: CustomEvent) => {
  console.log('图片加载成功', e.detail)
}

// 加载失败回调
const handleError = (e: CustomEvent) => {
  console.error('图片加载失败', e.detail)
}
</script>

<style>
/* 自定义加载样式 */
.my-loading {
  background: #f5f5f5;
  filter: blur(2px);
}

.lazy-loaded {
  transition: filter 0.3s ease;
  filter: blur(0);
}

.lazy-error {
  border: 1px solid #ff4444;
}
</style>

4. 手动控制加载

在组件内手动触发/重置加载:

<template>
  <img ref="imageRef" v-lazy-load="imageUrl" alt="手动控制" />
  <button @click="handleTriggerLoad">手动加载</button>
  <button @click="handleResetLoad">重置加载</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { triggerLoad, resetLoad } from './directives/lazyLoad'

const imageRef = ref<HTMLImageElement>(null)
const imageUrl = 'https://example.com/your-image.jpg'

// 手动触发加载
const handleTriggerLoad = () => {
  if (imageRef.value) {
    triggerLoad(imageRef.value)
  }
}

// 重置加载状态
const handleResetLoad = () => {
  if (imageRef.value) {
    resetLoad(imageRef.value)
  }
}
</script>

🔧 核心优化点说明

1. 性能优化

  • 全局观察器缓存:避免为每个元素创建独立的 IntersectionObserver 实例,减少内存占用
  • WeakMap 存储回调:自动回收无用的回调函数,防止内存泄漏
  • 统一清理函数:在元素卸载/更新时,彻底清理定时器、观察器、样式类

2. 重试机制优化

  • 指数退避策略:重试间隔从 1s 开始,每次翻倍(1s → 2s → 4s),最大不超过 5s,避免频繁重试占用资源
  • 每次重试创建新 Image 实例:避免浏览器缓存导致的重试无效问题
  • 状态锁机制:防止重复加载/重试,确保状态一致性

3. 易用性优化

  • 灵活的参数格式:支持字符串(仅图片地址)和对象(完整配置)两种绑定方式
  • 全局方法挂载:通过 $lazyLoad 可以在任意组件内调用手动控制方法
  • 完善的类型提示:TypeScript 类型扩展,开发时自动提示配置项和方法

4. 健壮性优化

  • 状态标记:通过 data-lazy-status 属性标记元素状态,方便调试和样式控制
  • 自定义事件:触发 lazy-loaded/lazy-error 事件,方便业务层处理回调
  • 跨域支持:默认设置 crossOrigin = 'anonymous',支持跨域图片加载

📋 关键配置项说明

配置项类型默认值说明
rootElement/Document/nullnull观察器的根元素,null 表示视口
rootMarginstring'0px'根元素的边距,用于扩展/收缩观察区域
thresholdnumber/number[]0.1元素可见比例阈值(0-1)
placeholderstring透明SVG加载前的占位图
errorstring带❌的SVG加载失败后的占位图
loadingClassstring'lazy-loading'加载中样式类
loadedClassstring'lazy-loaded'加载完成样式类
errorClassstring'lazy-error'加载失败样式类
attemptnumber3最大重试次数
srcstring-目标图片地址

🎨 样式示例

可以根据元素的 data-lazy-status 属性或样式类定制加载动画:

/* 加载中动画 */
.lazy-loading {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

/* 加载完成过渡 */
.lazy-loaded {
  transition: opacity 0.3s ease;
  opacity: 1;
}

/* 初始状态 */
img[data-lazy-status="pending"] {
  opacity: 0;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

📌 总结

本文实现的 Vue3 懒加载指令具备以下核心优势:

  1. 高性能:基于 IntersectionObserver,相比滚动监听性能提升显著
  2. 高可用:内置失败重试机制,提升图片加载成功率
  3. 高灵活:支持丰富的自定义配置,适配不同业务场景
  4. 高可维护:TypeScript 全类型支持,代码结构清晰,易于扩展
  5. 无内存泄漏:完善的资源清理逻辑,适配组件生命周期

这个指令可以直接用于生产环境,覆盖大部分图片懒加载场景。如果需要进一步扩展,可以在此基础上增加:

  • 支持背景图懒加载
  • 支持视频懒加载
  • 加载进度显示
  • 批量加载控制

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!