前言
在当今的 Web 应用中,图片资源已成为用户体验的关键因素。一个页面动辄几十张高清图片,如果全部同时加载,不仅浪费用户流量,还会导致页面卡顿甚至崩溃。据统计,图片懒加载可以减少 40-60% 的首屏加载时间。
本文将深入探讨 Vue3 中图片懒加载的实现原理,从底层的 IntersectionObserver 到高级的 LQIP 渐进式加载,最终手写一个完整的图片懒加载指令和预加载组件。
懒加载原理 - 怎么知道图片该加载了?
传统懒加载:监听滚动
传统懒加载通常通过监听滚动事件 + 计算元素位置实现:
javascript
window.addEventListener('scroll', () => {
// 获取所有图片
const images = document.querySelectorAll('img[data-src]')
images.forEach(img => {
// 计算图片位置
const rect = img.getBoundingClientRect()
// 如果图片进入可视区
if (rect.top < window.innerHeight) {
// 加载图片
img.src = img.dataset.src
img.removeAttribute('data-src')
}
})
})
问题:
- 滚动事件频繁触发,影响性能(需配合节流)
- 需要手动计算位置,代码复杂
- 无法处理元素在可视区内但不滚动的情况
现代方案:IntersectionObserver
IntersectionObserver 是现代浏览器提供的 API,用于异步观察元素与其祖先或视口的交叉状态。
javascript
// 创建观察器
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// 如果图片进入可视区
if (entry.isIntersecting) {
const img = entry.target
const src = img.dataset.src
// 加载图片
img.src = src
// 加载完就不再观察了
observer.unobserve(img)
}
})
})
// 观察所有图片
const images = document.querySelectorAll('img[data-src]')
images.forEach(img => observer.observe(img))
优势:
- 无需监听滚动,性能更好
- 自动处理元素在可视区的判断
- 提供精细的阈值配置
阈值设置的艺术
阈值(threshold)决定元素进入可视区多少时触发回调:
javascript
const observer = new IntersectionObserver(callback, {
// 阈值数组:触发回调的交叉比例
threshold: [0, 0.25, 0.5, 0.75, 1]
// 或者单个值
// threshold: 0.5 // 50% 可见时触发
})
不同阈值的适用场景
| 阈值 | 触发时机 | 适用场景 |
|---|---|---|
| 0 | 元素刚进入可视区 | 普通图片懒加载 |
| 0.1-0.3 | 元素部分可见 | 预加载、提前加载 |
| 0.5 | 元素半可见 | 视频自动播放 |
| 1 | 元素完全可见 | 广告曝光统计 |
自定义指令 v-lazy:让任何图片都能懒加载
指令的生命周期
在 Vue3 中,自定义指令的生命周期钩子包括:
javascript
const vLazy = {
// 在绑定元素的 attribute 或事件监听器被应用之前调用
created(el, binding, vnode) {},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode) {},
// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
mounted(el, binding, vnode) {
// 通常在这里初始化懒加载
},
// 在包含组件的 VNode 更新之前调用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在包含组件的 VNode 及其子组件的 VNode 更新之后调用
updated(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件卸载前调用
beforeUnmount(el, binding, vnode) {},
// 在绑定元素的父组件卸载后调用
unmounted(el, binding, vnode) {
// 清理工作
}
}
基础 v-lazy 指令实现
typescript
// directives/vLazy.ts
import { Directive } from 'vue'
interface LazyOptions {
loading?: string // 加载中占位图
error?: string // 加载失败占位图
threshold?: number // 阈值
rootMargin?: string // 扩展区域
}
class LazyManager {
private observer: IntersectionObserver | null = null
private cache = new Set<string>() // 已加载图片缓存
constructor(options: LazyOptions = {}) {
this.observer = new IntersectionObserver(
this.onIntersection.bind(this),
{
root: null,
rootMargin: options.rootMargin || '50px',
threshold: options.threshold || 0
}
)
}
private onIntersection(entries: IntersectionObserverEntry[]) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement
const src = img.dataset.src
if (src && !this.cache.has(src)) {
this.loadImage(img, src)
}
}
})
}
private loadImage(img: HTMLImageElement, src: string) {
const tempImg = new Image()
tempImg.onload = () => {
img.src = src
img.classList.add('loaded')
this.cache.add(src)
this.observer?.unobserve(img)
}
tempImg.onerror = () => {
img.src = img.dataset.error || ''
img.classList.add('error')
this.observer?.unobserve(img)
}
tempImg.src = src
}
add(img: HTMLImageElement, src: string, errorSrc?: string) {
if (this.cache.has(src)) {
// 已缓存,直接显示
img.src = src
} else {
// 设置占位图并开始观察
img.src = img.dataset.loading || ''
img.dataset.src = src
if (errorSrc) {
img.dataset.error = errorSrc
}
this.observer?.observe(img)
}
}
remove(img: HTMLImageElement) {
this.observer?.unobserve(img)
}
}
const lazyManager = new LazyManager()
export const vLazy: Directive<HTMLImageElement, string> = {
mounted(el, binding) {
const { value, modifiers } = binding
// 处理修饰符
const options = {
loading: modifiers.loading ? binding.instance?.loadingSrc : undefined,
error: modifiers.error ? binding.instance?.errorSrc : undefined
}
lazyManager.add(el, value, options.error)
},
updated(el, binding) {
if (binding.value !== binding.oldValue) {
lazyManager.add(el, binding.value)
}
},
unmounted(el) {
lazyManager.remove(el)
}
}
使用 v-lazy 指令
html
<template>
<div>
<!-- 直接使用,图片会自动懒加载 -->
<img v-lazy="imageUrl" alt="图片">
<!-- 也可以自定义占位图 -->
<img
v-lazy="imageUrl"
:loading-src="'/images/my-loading.gif'"
:error-src="'/images/my-error.png'"
alt="图片"
>
</div>
</template>
<script setup>
import { vLazy } from './directives/vLazy'
import { ref } from 'vue'
const imageUrl = ref('https://example.com/large-image.jpg')
</script>
渐进式加载(LQIP):先模糊后清晰
什么是渐进式加载?
javascript
先看到模糊的占位图(很小)
↓
等高清图加载完成
↓
平滑过渡到高清图
LQIP 原理
LQIP (Low Quality Image Placeholders) 的核心思想:先展示一个极低质量的模糊图,等原图加载完成后,平滑过渡到高清图。
graph LR
A[原始图片] --> B[生成缩略图]
B --> C[Base64/内联]
C --> D[展示模糊占位]
D --> E[加载原图]
E --> F[平滑过渡]
如何生成缩略图?
方案一:构建时生成(推荐)
javascript
// vite.config.js 配合 imagemin 生成缩略图
import imagemin from 'imagemin'
import imageminJpegtran from 'imagemin-jpegtran'
// 构建时自动生成缩略图
await imagemin(['src/assets/**/*.{jpg,png}'], {
destination: 'dist/thumbnails',
plugins: [
imageminJpegtran({ progressive: true })
]
})
方案二:运行时动态生成(性能差,不适合大量图片)
javascript
function generateThumbnail(file, maxSize = 20) {
return new Promise((resolve) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
// 缩小图片
canvas.width = maxSize
canvas.height = (img.height / img.width) * maxSize
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
resolve(canvas.toDataURL('image/jpeg', 0.5))
}
img.src = e.target.result
}
reader.readAsDataURL(file)
})
}
方案三:使用现成的 CDN 服务
javascript
const thumbnailUrl = `https://images.example.com/w=20,q=30/${originalPath}`
使用渐进式图片组件
html
<template>
<div class="gallery">
<ProgressiveImage
v-for="item in images"
:key="item.id"
:src="item.src"
:thumbnail="item.thumbnail"
:alt="item.alt"
/>
</div>
</template>
<script setup>
import ProgressiveImage from './ProgressiveImage.vue'
const images = ref([
{
id: 1,
src: 'https://example.com/high-quality.jpg',
thumbnail: 'https://example.com/low-quality.jpg',
alt: '风景图'
}
])
</script>
加载失败兜底:完善的错误处理机制
基础错误处理
html
<template>
<div class="image-wrapper">
<img
ref="imgRef"
:src="currentSrc"
:alt="alt"
@error="handleError"
@load="handleLoad"
/>
<div v-if="loadingFailed" class="error-overlay">
<span>⚠️ 图片加载失败</span>
<button @click="retry">重试</button>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
src: String,
alt: String,
fallbackSrc: { type: String, default: '/images/fallback.png' },
retryCount: { type: Number, default: 3 }
})
const imgRef = ref()
const currentSrc = ref(props.src)
const loadingFailed = ref(false)
const retryAttempts = ref(0)
const handleError = () => {
if (retryAttempts.value < props.retryCount) {
// 重试
retryAttempts.value++
setTimeout(() => {
currentSrc.value = props.src + `?retry=${retryAttempts.value}`
}, 1000 * retryAttempts.value) // 指数退避
} else {
// 使用兜底图
currentSrc.value = props.fallbackSrc
loadingFailed.value = true
}
}
const handleLoad = () => {
loadingFailed.value = false
retryAttempts.value = 0
}
// 监听 src 变化,重置状态
watch(() => props.src, (newSrc) => {
currentSrc.value = newSrc
loadingFailed.value = false
retryAttempts.value = 0
})
</script>
指数退避重试算法
typescript
class RetryStrategy {
private attempts = 0
private maxAttempts: number
private baseDelay: number
constructor(maxAttempts = 3, baseDelay = 1000) {
this.maxAttempts = maxAttempts
this.baseDelay = baseDelay
}
getDelay(): number {
this.attempts++
if (this.attempts > this.maxAttempts) {
return -1 // 不再重试
}
// 指数退避:baseDelay * 2^(attempts-1)
return this.baseDelay * Math.pow(2, this.attempts - 1)
}
reset(): void {
this.attempts = 0
}
}
// 使用
const strategy = new RetryStrategy(5, 500)
const delay = strategy.getDelay()
if (delay > 0) {
setTimeout(() => retry(), delay)
}
多种占位图策略
typescript
const placeholders = {
// 纯色背景 + 文字
color: (color = '#f0f2f5', text = '暂无图片') => {
const canvas = document.createElement('canvas')
canvas.width = 200
canvas.height = 200
const ctx = canvas.getContext('2d')
ctx.fillStyle = color
ctx.fillRect(0, 0, 200, 200)
ctx.fillStyle = '#999'
ctx.font = '14px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(text, 100, 100)
return canvas.toDataURL()
},
// 内置图标
icon: (type = 'image') => {
// 返回内置图标 URL
return `/icons/placeholder-${type}.svg`
},
// 纯 Base64 透明图
transparent: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
}
虚拟列表 + 懒加载:处理大量图片
问题分析
在虚拟列表中,大量图片同时存在,比如 1000 张。如果全部进行 IntersectionObserver 观察,仍会造成性能问题。最佳策略是:只观察可视区附近的元素。
虚拟列表 + 懒加载的实现
html
<template>
<div
ref="containerRef"
class="virtual-list"
@scroll="onScroll"
>
<div
class="list-phantom"
:style="{ height: totalHeight + 'px' }"
></div>
<div
class="list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="list-item"
:style="{ height: itemHeight + 'px' }"
>
<!-- 只在可视区内的图片才加载 -->
<ProgressiveImage
v-if="shouldLoadImage(item)"
:src="item.src"
:thumbnail="item.thumbnail"
/>
<div v-else class="placeholder"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import ProgressiveImage from './ProgressiveImage.vue'
const props = defineProps({
items: Array,
itemHeight: { type: Number, default: 200 },
buffer: { type: Number, default: 5 } // 缓冲区大小
})
const containerRef = ref()
const scrollTop = ref(0)
// 计算可视区域
const visibleCount = computed(() =>
Math.ceil(containerRef.value?.clientHeight / props.itemHeight)
)
// 计算起始索引(带上缓冲区)
const startIndex = computed(() => {
let index = Math.floor(scrollTop.value / props.itemHeight)
return Math.max(0, index - props.buffer)
})
// 计算结束索引
const endIndex = computed(() => {
let index = startIndex.value + visibleCount.value + props.buffer * 2
return Math.min(index, props.items.length)
})
// 可视区域内的项目
const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
)
// 总高度
const totalHeight = computed(() =>
props.items.length * props.itemHeight
)
// 偏移量
const offsetY = computed(() =>
startIndex.value * props.itemHeight
)
// 判断图片是否应该加载
const shouldLoadImage = (item) => {
const index = props.items.indexOf(item)
// 只加载可视区及前后 buffer 范围内的图片
return index >= startIndex.value && index < endIndex.value
}
const onScroll = () => {
scrollTop.value = containerRef.value.scrollTop
}
</script>
性能对比
| 方案 | DOM 节点数 | 内存占用 | 滚动帧率 |
|---|---|---|---|
| 直接渲染 | 10000 | 80MB | 5-10fps |
| 懒加载 | 10000 | 80MB | 15-20fps |
| 虚拟列表 + 懒加载 | 20 | 5MB | 60fps |
预加载 - 提前加载重要图片
什么情况需要预加载?
用户即将看到的图片,都需要进行预加载:
- 轮播图的下一张
- 鼠标悬停的图片
- 首屏的关键图片
- 预计用户会看的图片
带进度条的预加载组件
html
<template>
<div class="preload-container">
<!-- 预加载进度条 -->
<div v-if="loading" class="progress-wrapper">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<div class="progress-text">{{ Math.round(progress) }}%</div>
</div>
<!-- 加载完成后显示图片 -->
<img v-else :src="currentSrc" :alt="alt" />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps({
src: String,
alt: String,
priority: { type: Boolean, default: false } // 是否高优先级
})
const currentSrc = ref('')
const loading = ref(true)
const progress = ref(0)
// 使用 XMLHttpRequest 实现进度跟踪
const loadImage = () => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', props.src, true)
xhr.responseType = 'blob'
xhr.onload = () => {
if (xhr.status === 200) {
const blob = xhr.response
const url = URL.createObjectURL(blob)
resolve(url)
} else {
reject(new Error('加载失败'))
}
}
xhr.onerror = reject
xhr.onprogress = (e) => {
if (e.lengthComputable) {
progress.value = (e.loaded / e.total) * 100
}
}
xhr.send()
})
}
onMounted(async () => {
try {
const url = await loadImage()
currentSrc.value = url
loading.value = false
} catch (error) {
console.error('图片加载失败', error)
loading.value = false
}
})
onUnmounted(() => {
if (currentSrc.value) {
URL.revokeObjectURL(currentSrc.value)
}
})
</script>
<style scoped>
.progress-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.progress-bar {
width: 200px;
height: 4px;
background: #f0f0f0;
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #1890ff;
transition: width 0.1s ease;
}
.progress-text {
font-size: 12px;
color: #666;
}
</style>
最佳实践清单
配置清单
- 使用 IntersectionObserver 实现懒加载
- 封装 v-lazy 指令,方便复用
- 大图使用渐进式加载(先模糊后清晰)
- 配置错误重试机制(指数退避)
- 大量图片使用虚拟列表
- 重要图片使用预加载
- 添加 loading 动画或骨架屏
不同场景的选择
| 场景 | 技术方案 | 性能收益 |
|---|---|---|
| 普通图片 | v-lazy 指令 + IntersectionObserver | 减少 80% 首屏请求 |
| 高质量图片 | LQIP + 平滑过渡 | 提升 60% 感知性能 |
| 图片墙/画廊 | 虚拟列表 + 按需加载 | 内存占用减少 90% |
| 关键图片 | 预加载 + 进度条 | 用户体验提升 |
| 轮播图 | 预加载下一张 | 流畅切换 |
实施清单
- 懒加载基础:使用 IntersectionObserver 实现 v-lazy 指令
- 渐进式增强:LQIP 模糊占位 + 高清图过渡
- 错误处理:重试机制 + 兜底图
- 性能优化:虚拟列表 + 缓冲区控制
- 用户体验:进度反馈 + 平滑动画
最后的建议
图片加载优化不是单一技术,而是一个系统工程:
- 网络层面:使用 HTTP/2、CDN 加速
- 构建层面:压缩、格式转换、雪碧图
- 运行时层面:懒加载、预加载、缓存
- 体验层面:进度反馈、平滑过渡
结语
好的图片加载策略应该是无感知的。用户不会注意到图片是懒加载的,不会注意到有进度条,他们只会感觉页面"很快很流畅"。这才是优化的最高境界。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!