🔥 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 懒加载指令具备以下核心优势:
- 高性能:基于 IntersectionObserver,相比滚动监听性能提升显著
- 高可用:内置失败重试机制,提升图片加载成功率
- 高灵活:支持丰富的自定义配置,适配不同业务场景
- 高可维护:TypeScript 全类型支持,代码结构清晰,易于扩展
- 无内存泄漏:完善的资源清理逻辑,适配组件生命周期
这个指令可以直接用于生产环境,覆盖大部分图片懒加载场景。如果需要进一步扩展,可以在此基础上增加:
- 支持背景图懒加载
- 支持视频懒加载
- 加载进度显示
- 批量加载控制
希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!