阅读视图

发现新文章,点击刷新页面。

🔥 Vue3 + TS 实现一键复制指令 v-copy:优雅解决文本复制需求

🔥 Vue3 + TS 实现一键复制指令 v-copy:优雅解决文本复制需求

在前端开发中,“一键复制”是高频使用的交互功能(如复制链接、订单号、邀请码等)。本文将教你基于 Vue3 + TypeScript 实现一个功能完善、体验友好、类型安全v-copy 自定义指令,支持自定义复制内容、复制成功/失败回调、复制提示等特性,开箱即用。 在这里插入图片描述

🎯 指令核心特性

  • ✅ 支持复制元素文本/自定义内容,灵活适配不同场景
  • ✅ 复制成功/失败回调,便于业务层处理交互反馈
  • ✅ 内置复制成功提示(可自定义),提升用户体验
  • ✅ 完整 TypeScript 类型定义,开发提示友好
  • ✅ 自动兼容剪贴板 API,低版本浏览器友好提示
  • ✅ 支持指令参数动态更新,适配动态内容
  • ✅ 无第三方依赖,轻量高效

📁 完整代码实现(v-copy.ts)

// directives/v-copy.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 复制指令配置接口
 */
export interface CopyOptions {
  /** 要复制的内容,优先级高于元素文本 */
  content?: string
  /** 复制成功回调 */
  onSuccess?: (text: string) => void
  /** 复制失败回调 */
  onError?: (error: Error) => void
  /** 复制成功提示文本,默认"复制成功" */
  successTip?: string
  /** 提示显示时长(ms),默认2000 */
  tipDuration?: number
  /** 是否显示复制提示,默认true */
  showTip?: boolean
}

/**
 * 扩展元素属性,存储复制相关状态
 */
interface CopyElement extends HTMLElement {
  _copy?: {
    options: CopyOptions
    tipElement?: HTMLDivElement // 提示元素
    tipTimer?: number | null    // 提示定时器
    clickHandler: (e: MouseEvent) => void // 点击事件处理函数
  }
}

/**
 * 默认配置
 */
const DEFAULT_OPTIONS: CopyOptions = {
  successTip: '复制成功',
  tipDuration: 2000,
  showTip: true
}

/**
 * 创建复制成功提示元素
 * @param el 目标元素
 * @param text 提示文本
 * @returns 提示元素
 */
const createTipElement = (el: CopyElement, text: string): HTMLDivElement => {
  // 若已有提示元素,先移除
  if (el._copy?.tipElement) {
    document.body.removeChild(el._copy.tipElement)
    if (el._copy.tipTimer) {
      clearTimeout(el._copy.tipTimer)
      el._copy.tipTimer = null
    }
  }

  // 创建提示元素
  const tip = document.createElement('div')
  tip.style.position = 'absolute'
  tip.style.padding = '4px 12px'
  tip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
  tip.style.color = '#fff'
  tip.style.borderRadius = '4px'
  tip.style.fontSize = '14px'
  tip.style.zIndex = '9999'
  tip.style.transition = 'opacity 0.3s ease'
  tip.textContent = text

  // 计算提示位置(目标元素中心)
  const rect = el.getBoundingClientRect()
  const top = rect.top + window.scrollY - 40
  const left = rect.left + window.scrollX + (rect.width - tip.offsetWidth) / 2

  tip.style.top = `${top}px`
  tip.style.left = `${left}px`

  return tip
}

/**
 * 显示复制提示
 * @param el 目标元素
 * @param text 提示文本
 * @param duration 显示时长
 */
const showCopyTip = (el: CopyElement, text: string, duration: number) => {
  if (!el._copy) return

  // 创建并挂载提示元素
  const tip = createTipElement(el, text)
  document.body.appendChild(tip)
  el._copy.tipElement = tip

  // 定时隐藏提示
  el._copy.tipTimer = window.setTimeout(() => {
    tip.style.opacity = '0'
    setTimeout(() => {
      document.body.removeChild(tip)
      el._copy!.tipElement = undefined
      el._copy!.tipTimer = null
    }, 300)
  }, duration) as unknown as number
}

/**
 * 核心复制逻辑
 * @param text 要复制的文本
 * @returns Promise<string> 复制成功的文本
 */
const copyToClipboard = async (text: string): Promise<string> => {
  // 优先使用 Clipboard API(现代浏览器)
  if (navigator.clipboard && window.isSecureContext) {
    try {
      await navigator.clipboard.writeText(text)
      return text
    } catch (err) {
      // 降级处理
      throw new Error(`剪贴板API复制失败: ${(err as Error).message}`)
    }
  }

  // 降级方案:创建临时textarea元素
  const textarea = document.createElement('textarea')
  textarea.value = text
  // 隐藏textarea
  textarea.style.position = 'absolute'
  textarea.style.opacity = '0'
  textarea.style.pointerEvents = 'none'
  document.body.appendChild(textarea)

  try {
    // 选中并复制
    textarea.select()
    textarea.setSelectionRange(0, textarea.value.length) // 兼容移动设备
    const success = document.execCommand('copy')
    if (!success) {
      throw new Error('execCommand复制失败')
    }
    return text
  } finally {
    // 清理临时元素
    document.body.removeChild(textarea)
  }
}

/**
 * 清理复制相关资源
 * @param el 目标元素
 */
const cleanup = (el: CopyElement) => {
  const copyData = el._copy
  if (!copyData) return

  // 移除点击事件
  el.removeEventListener('click', copyData.clickHandler)
  
  // 清理提示定时器和元素
  if (copyData.tipTimer) {
    clearTimeout(copyData.tipTimer)
    copyData.tipTimer = null
  }
  if (copyData.tipElement) {
    document.body.removeChild(copyData.tipElement)
    copyData.tipElement = undefined
  }
  
  // 删除扩展属性
  delete el._copy
}

// 创建独立的初始化函数
const initializeCopy = (el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) => {
  // 将 mounted 中的所有逻辑移动到这里
  // 1. 解析指令参数
  let options: CopyOptions = { ...DEFAULT_OPTIONS }
  
  if (typeof binding.value === 'string') {
    options.content = binding.value
  } else if (typeof binding.value === 'object' && binding.value !== null) {
    options = { ...DEFAULT_OPTIONS, ...binding.value }
  }
  // 2. 定义点击处理函数
  const clickHandler = async (e: MouseEvent) => {
    e.preventDefault()
    
    const copyText = options.content || el.textContent?.trim() || ''
    if (!copyText) {
      const error = new Error('无可用的复制内容')
      options.onError?.(error)
      console.warn('[v-copy] 无可用的复制内容')
      return
    }
    try {
      await copyToClipboard(copyText)
      options.onSuccess?.(copyText)
      
      if (options.showTip) {
        showCopyTip(el, options.successTip!, options.tipDuration!)
      }
    } catch (error) {
      const err = error as Error
      options.onError?.(err)
      console.error('[v-copy] 复制失败:', err.message)
      
      if (options.showTip) {
        showCopyTip(el, '复制失败', options.tipDuration!)
      }
    }
  }
  // 3. 绑定点击事件
  el.addEventListener('click', clickHandler)
  // 4. 存储状态到元素
  el._copy = {
    options,
    clickHandler,
    tipTimer: null
  }
  // 5. 给元素添加可点击样式提示
  el.style.cursor = 'pointer'
}

/**
 * v-copy 自定义指令实现
 */
export const copyDirective: ObjectDirective<CopyElement, CopyOptions | string> = {
  /**
   * 指令挂载时初始化
   */
  mounted(el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) {
   initializeCopy(el, binding)
  },

  /**
   * 指令更新时处理参数变化
   */
  updated(el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) {
    // 先清理旧配置
    cleanup(el)
    // 重新初始化
    initializeCopy(el, binding)
  },

  /**
   * 指令卸载时清理资源
   */
  unmounted(el: CopyElement) {
    cleanup(el)
  }
}

/**
 * 全局注册复制指令
 * @param app Vue应用实例
 * @param directiveName 指令名称,默认copy
 */
export const setupCopyDirective = (app: App, directiveName: string = 'copy') => {
  app.directive(directiveName, copyDirective)
}

// TypeScript 类型扩展
declare module 'vue' {
  export interface ComponentCustomDirectives {
    copy: typeof copyDirective
  }
}

🚀 快速上手

1. 全局注册指令(main.ts)

在 Vue3 入口文件中注册指令,全局可用:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupCopyDirective } from './directives/v-copy'

const app = createApp(App)

// 注册复制指令(默认名称v-copy)
setupCopyDirective(app)

app.mount('#app')

2. 基础使用(直接传复制内容)

最简单的用法:直接传递要复制的字符串,使用默认配置(显示“复制成功”提示):

<template>
  <!-- 复制固定文本 -->
  <button v-copy="'https://github.com/your-repo'">
    复制我的GitHub地址
  </button>

  <!-- 复制元素文本内容 -->
  <div v-copy style="cursor: pointer;">
    点击复制这段文本:1234567890
  </div>
</template>

3. 高级使用(自定义配置)

通过对象参数配置完整的复制规则,支持自定义提示、回调函数:

<template>
  <div>
    <input v-model="copyText" placeholder="输入要复制的内容" />
    
    <button 
      v-copy="{
        content: copyText,
        successTip: '链接复制成功啦~',
        tipDuration: 1500,
        onSuccess: handleCopySuccess,
        onError: handleCopyError
      }"
    >
      自定义复制配置
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const copyText = ref('https://example.com/custom-link')

// 复制成功回调
const handleCopySuccess = (text: string) => {
  console.log('复制成功,内容:', text)
  // 可在这里添加自定义提示,如使用Element Plus的Message
  // ElMessage.success(`复制成功:${text}`)
}

// 复制失败回调
const handleCopyError = (error: Error) => {
  console.error('复制失败:', error)
  // ElMessage.error('复制失败,请手动复制')
}
</script>

4. 动态内容复制

适配动态变化的复制内容(如接口返回的订单号、邀请码):

<template>
  <div>
    <div>您的邀请码:<span v-copy="inviteCode">{{ inviteCode }}</span></div>
    <button @click="refreshInviteCode">刷新邀请码</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 模拟动态邀请码
const inviteCode = ref('ABC123456')

// 刷新邀请码
const refreshInviteCode = () => {
  // 生成随机邀请码
  const randomCode = Math.random().toString(36).substring(2, 8).toUpperCase()
  inviteCode.value = randomCode
}
</script>

<style>
/* 给可复制元素添加样式提示 */
span[V-copy] {
  color: #409eff;
  text-decoration: underline;
  cursor: pointer;
}
</style>

5. 关闭默认提示(自定义反馈)

若需自定义复制反馈(如使用UI库的提示组件),可关闭默认提示:

<template>
  <button 
    v-copy="{
      content: '自定义反馈示例',
      showTip: false,
      onSuccess: () => ElMessage.success('复制成功✅'),
      onError: () => ElMessage.error('复制失败❌')
    }"
  >
    自定义反馈提示
  </button>
</template>

<script setup lang="ts">
import { ElMessage } from 'element-plus'
</script>

🔧 核心知识点解析

1. 复制实现原理

指令兼容两种复制方案,保证最大兼容性:

  • 现代浏览器:使用 navigator.clipboard.writeText()(异步、安全,推荐)
  • 降级方案:创建临时 textarea 元素,通过 document.execCommand('copy') 复制(兼容低版本浏览器)

2. 提示组件实现

  • 动态创建提示元素,基于目标元素位置计算居中显示
  • 使用定时器自动隐藏提示,添加过渡动画提升体验
  • 重复点击时自动清理旧提示,避免多个提示叠加

3. 内存泄漏防护

  • unmounted 钩子中移除点击事件、清理定时器、删除临时提示元素
  • updated 钩子中先清理旧配置,再初始化新配置
  • 扩展元素属性存储状态,卸载时删除属性释放内存

4. TypeScript 类型优化

  • 定义 CopyOptions 接口,明确配置项类型
  • 扩展 HTMLElement 类型,添加复制状态属性
  • 支持两种参数类型(字符串/对象),类型推导自动适配

📋 配置项说明

配置项 类型 默认值 说明
content string - 要复制的内容,优先级高于元素文本
onSuccess (text: string) => void - 复制成功回调,参数为复制的文本
onError (error: Error) => void - 复制失败回调,参数为错误对象
successTip string '复制成功' 复制成功提示文本
tipDuration number 2000 提示显示时长,单位ms
showTip boolean true 是否显示默认的复制提示

🎯 常见使用场景

场景1:复制订单号/优惠券码

<template>
  <div class="order-card">
    <div class="label">订单号:</div>
    <div class="value" v-copy="{ successTip: '订单号复制成功' }">
      {{ orderNo }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// 模拟接口返回的订单号
const orderNo = ref('ORD20240124123456789')
</script>

<style>
.order-card {
  display: flex;
  align-items: center;
  padding: 10px;
}
.label {
  margin-right: 8px;
  color: #666;
}
.value {
  color: #409eff;
  cursor: pointer;
}
</style>

场景2:复制分享链接

<template>
  <div class="share-card">
    <input 
      type="text" 
      readonly 
      v-model="shareLink"
      class="link-input"
    />
    <button 
      v-copy="{
        content: shareLink,
        onSuccess: () => ElMessage.success('分享链接已复制'),
        onError: () => ElMessage.error('复制失败,请手动复制')
      }"
      class="copy-btn"
    >
      复制链接
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

// 模拟生成分享链接
const shareLink = ref(`https://example.com/share?uid=${Math.random().toString(36).substring(2, 10)}`)
</script>

<style>
.share-card {
  display: flex;
  gap: 8px;
  padding: 10px;
}
.link-input {
  flex: 1;
  padding: 6px;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
}
.copy-btn {
  padding: 6px 12px;
  background: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

🚨 注意事项

  1. Clipboard API 安全限制navigator.clipboard 仅在安全上下文(HTTPS)或本地开发环境(localhost)中可用,HTTP 环境会自动降级到 execCommand
  2. 移动端兼容性:部分移动端浏览器对 execCommand('copy') 支持有限,建议在实际项目中测试。
  3. 复制内容为空:指令会校验复制内容,为空时触发 onError 并打印警告,需确保复制内容有效。
  4. 样式提示:指令会自动给元素添加 cursor: pointer,可根据需要覆盖样式。

📌 总结

本文实现的 v-copy 指令具备以下核心优势:

  1. 兼容性强:兼容现代浏览器和低版本浏览器,自动降级处理。
  2. 体验友好:内置居中提示,支持自定义提示文本和时长,交互体验佳。
  3. 配置灵活:支持自定义复制内容、成功/失败回调,适配各种业务场景。
  4. 类型安全:基于 TypeScript 开发,类型提示完善,减少开发错误。
  5. 轻量无依赖:无需引入第三方库,体积小,性能优。

这个指令可以直接集成到你的 Vue3 项目中,解决各种文本复制需求。如果需要进一步扩展,可以在此基础上增加:

  • 支持复制富文本(HTML内容)
  • 支持自定义提示样式(颜色、位置、动画)
  • 支持双击复制/长按复制
  • 支持复制后自动清空输入框

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

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

🔥 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',支持跨域图片加载

📋 关键配置项说明

配置项 类型 默认值 说明
root Element/Document/null null 观察器的根元素,null 表示视口
rootMargin string '0px' 根元素的边距,用于扩展/收缩观察区域
threshold number/number[] 0.1 元素可见比例阈值(0-1)
placeholder string 透明SVG 加载前的占位图
error string 带❌的SVG 加载失败后的占位图
loadingClass string 'lazy-loading' 加载中样式类
loadedClass string 'lazy-loaded' 加载完成样式类
errorClass string 'lazy-error' 加载失败样式类
attempt number 3 最大重试次数
src string - 目标图片地址

🎨 样式示例

可以根据元素的 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. 无内存泄漏:完善的资源清理逻辑,适配组件生命周期

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

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

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

❌