深入探討Vue3組件中的圖片懶加載的實(shí)現(xiàn)原理與應(yīng)用
前言
在當(dāng)今的 Web 應(yīng)用中,圖片資源已成為用戶(hù)體驗(yàn)的關(guān)鍵因素。一個(gè)頁(yè)面動(dòng)輒幾十張高清圖片,如果全部同時(shí)加載,不僅浪費(fèi)用戶(hù)流量,還會(huì)導(dǎo)致頁(yè)面卡頓甚至崩潰。據(jù)統(tǒng)計(jì),圖片懶加載可以減少 40-60% 的首屏加載時(shí)間。
本文將深入探討 Vue3 中圖片懶加載的實(shí)現(xiàn)原理,從底層的 IntersectionObserver 到高級(jí)的 LQIP 漸進(jìn)式加載,最終手寫(xiě)一個(gè)完整的圖片懶加載指令和預(yù)加載組件。
懶加載原理 - 怎么知道圖片該加載了
傳統(tǒng)懶加載:監(jiān)聽(tīng)滾動(dòng)
傳統(tǒng)懶加載通常通過(guò)監(jiān)聽(tīng)滾動(dòng)事件 + 計(jì)算元素位置實(shí)現(xiàn):
window.addEventListener('scroll', () => {
// 獲取所有圖片
const images = document.querySelectorAll('img[data-src]')
images.forEach(img => {
// 計(jì)算圖片位置
const rect = img.getBoundingClientRect()
// 如果圖片進(jìn)入可視區(qū)
if (rect.top < window.innerHeight) {
// 加載圖片
img.src = img.dataset.src
img.removeAttribute('data-src')
}
})
})
問(wèn)題:
- 滾動(dòng)事件頻繁觸發(fā),影響性能(需配合節(jié)流)
- 需要手動(dòng)計(jì)算位置,代碼復(fù)雜
- 無(wú)法處理元素在可視區(qū)內(nèi)但不滾動(dòng)的情況
現(xiàn)代方案:IntersectionObserver
IntersectionObserver 是現(xiàn)代瀏覽器提供的 API,用于異步觀(guān)察元素與其祖先或視口的交叉狀態(tài)。
// 創(chuàng)建觀(guān)察器
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// 如果圖片進(jìn)入可視區(qū)
if (entry.isIntersecting) {
const img = entry.target
const src = img.dataset.src
// 加載圖片
img.src = src
// 加載完就不再觀(guān)察了
observer.unobserve(img)
}
})
})
// 觀(guān)察所有圖片
const images = document.querySelectorAll('img[data-src]')
images.forEach(img => observer.observe(img))優(yōu)勢(shì):
- 無(wú)需監(jiān)聽(tīng)滾動(dòng),性能更好
- 自動(dòng)處理元素在可視區(qū)的判斷
- 提供精細(xì)的閾值配置
閾值設(shè)置的藝術(shù)
閾值(threshold)決定元素進(jìn)入可視區(qū)多少時(shí)觸發(fā)回調(diào):
const observer = new IntersectionObserver(callback, {
// 閾值數(shù)組:觸發(fā)回調(diào)的交叉比例
threshold: [0, 0.25, 0.5, 0.75, 1]
// 或者單個(gè)值
// threshold: 0.5 // 50% 可見(jiàn)時(shí)觸發(fā)
})
不同閾值的適用場(chǎng)景
| 閾值 | 觸發(fā)時(shí)機(jī) | 適用場(chǎng)景 |
|---|---|---|
| 0 | 元素剛進(jìn)入可視區(qū) | 普通圖片懶加載 |
| 0.1-0.3 | 元素部分可見(jiàn) | 預(yù)加載、提前加載 |
| 0.5 | 元素半可見(jiàn) | 視頻自動(dòng)播放 |
| 1 | 元素完全可見(jiàn) | 廣告曝光統(tǒng)計(jì) |
自定義指令 v-lazy:讓任何圖片都能懶加載
指令的生命周期
在 Vue3 中,自定義指令的生命周期鉤子包括:
const vLazy = {
// 在綁定元素的 attribute 或事件監(jiān)聽(tīng)器被應(yīng)用之前調(diào)用
created(el, binding, vnode) {},
// 在元素被插入到 DOM 前調(diào)用
beforeMount(el, binding, vnode) {},
// 在綁定元素的父組件及他自己的所有子節(jié)點(diǎn)都掛載完成后調(diào)用
mounted(el, binding, vnode) {
// 通常在這里初始化懶加載
},
// 在包含組件的 VNode 更新之前調(diào)用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在包含組件的 VNode 及其子組件的 VNode 更新之后調(diào)用
updated(el, binding, vnode, prevVnode) {},
// 在綁定元素的父組件卸載前調(diào)用
beforeUnmount(el, binding, vnode) {},
// 在綁定元素的父組件卸載后調(diào)用
unmounted(el, binding, vnode) {
// 清理工作
}
}
基礎(chǔ) v-lazy 指令實(shí)現(xiàn)
// directives/vLazy.ts
import { Directive } from 'vue'
interface LazyOptions {
loading?: string // 加載中占位圖
error?: string // 加載失敗占位圖
threshold?: number // 閾值
rootMargin?: string // 擴(kuò)展區(qū)域
}
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 {
// 設(shè)置占位圖并開(kāi)始觀(guān)察
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 指令
<template>
<div>
<!-- 直接使用,圖片會(huì)自動(dòng)懶加載 -->
<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>漸進(jìn)式加載(LQIP):先模糊后清晰
什么是漸進(jìn)式加載
先看到模糊的占位圖(很?。?br /> ↓
等高清圖加載完成
↓
平滑過(guò)渡到高清圖
LQIP 原理
LQIP (Low Quality Image Placeholders) 的核心思想:先展示一個(gè)極低質(zhì)量的模糊圖,等原圖加載完成后,平滑過(guò)渡到高清圖。

如何生成縮略圖
方案一:構(gòu)建時(shí)生成(推薦)
// vite.config.js 配合 imagemin 生成縮略圖
import imagemin from 'imagemin'
import imageminJpegtran from 'imagemin-jpegtran'
// 構(gòu)建時(shí)自動(dòng)生成縮略圖
await imagemin(['src/assets/**/*.{jpg,png}'], {
destination: 'dist/thumbnails',
plugins: [
imageminJpegtran({ progressive: true })
]
})
方案二:運(yùn)行時(shí)動(dòng)態(tài)生成(性能差,不適合大量圖片)
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)
})
}
方案三:使用現(xiàn)成的 CDN 服務(wù)
const thumbnailUrl = `https://images.example.com/w=20,q=30/${originalPath}`
使用漸進(jìn)式圖片組件
<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: '風(fēng)景圖'
}
])
</script>加載失敗兜底:完善的錯(cuò)誤處理機(jī)制
基礎(chǔ)錯(cuò)誤處理
<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) // 指數(shù)退避
} else {
// 使用兜底圖
currentSrc.value = props.fallbackSrc
loadingFailed.value = true
}
}
const handleLoad = () => {
loadingFailed.value = false
retryAttempts.value = 0
}
// 監(jiān)聽(tīng) src 變化,重置狀態(tài)
watch(() => props.src, (newSrc) => {
currentSrc.value = newSrc
loadingFailed.value = false
retryAttempts.value = 0
})
</script>指數(shù)退避重試算法
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 // 不再重試
}
// 指數(shù)退避: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)
}
多種占位圖策略
const placeholders = {
// 純色背景 + 文字
color: (color = '#f0f2f5', text = '暫無(wú)圖片') => {
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()
},
// 內(nèi)置圖標(biāo)
icon: (type = 'image') => {
// 返回內(nèi)置圖標(biāo) URL
return `/icons/placeholder-${type}.svg`
},
// 純 Base64 透明圖
transparent: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
}
虛擬列表 + 懶加載:處理大量圖片
問(wèn)題分析
在虛擬列表中,大量圖片同時(shí)存在,比如 1000 張。如果全部進(jìn)行 IntersectionObserver 觀(guān)察,仍會(huì)造成性能問(wèn)題。最佳策略是:只觀(guān)察可視區(qū)附近的元素。
虛擬列表 + 懶加載的實(shí)現(xiàn)
<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' }"
>
<!-- 只在可視區(qū)內(nèi)的圖片才加載 -->
<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 } // 緩沖區(qū)大小
})
const containerRef = ref()
const scrollTop = ref(0)
// 計(jì)算可視區(qū)域
const visibleCount = computed(() =>
Math.ceil(containerRef.value?.clientHeight / props.itemHeight)
)
// 計(jì)算起始索引(帶上緩沖區(qū))
const startIndex = computed(() => {
let index = Math.floor(scrollTop.value / props.itemHeight)
return Math.max(0, index - props.buffer)
})
// 計(jì)算結(jié)束索引
const endIndex = computed(() => {
let index = startIndex.value + visibleCount.value + props.buffer * 2
return Math.min(index, props.items.length)
})
// 可視區(qū)域內(nèi)的項(xiàng)目
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
)
// 判斷圖片是否應(yīng)該加載
const shouldLoadImage = (item) => {
const index = props.items.indexOf(item)
// 只加載可視區(qū)及前后 buffer 范圍內(nèi)的圖片
return index >= startIndex.value && index < endIndex.value
}
const onScroll = () => {
scrollTop.value = containerRef.value.scrollTop
}
</script>性能對(duì)比
| 方案 | DOM 節(jié)點(diǎn)數(shù) | 內(nèi)存占用 | 滾動(dòng)幀率 |
|---|---|---|---|
| 直接渲染 | 10000 | 80MB | 5-10fps |
| 懶加載 | 10000 | 80MB | 15-20fps |
| 虛擬列表 + 懶加載 | 20 | 5MB | 60fps |
預(yù)加載 - 提前加載重要圖片
什么情況需要預(yù)加載
用戶(hù)即將看到的圖片,都需要進(jìn)行預(yù)加載:
- 輪播圖的下一張
- 鼠標(biāo)懸停的圖片
- 首屏的關(guān)鍵圖片
- 預(yù)計(jì)用戶(hù)會(huì)看的圖片
帶進(jìn)度條的預(yù)加載組件
<template>
<div class="preload-container">
<!-- 預(yù)加載進(jìn)度條 -->
<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 } // 是否高優(yōu)先級(jí)
})
const currentSrc = ref('')
const loading = ref(true)
const progress = ref(0)
// 使用 XMLHttpRequest 實(shí)現(xiàn)進(jìn)度跟蹤
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>最佳實(shí)踐清單
配置清單
- 使用 IntersectionObserver 實(shí)現(xiàn)懶加載
- 封裝 v-lazy 指令,方便復(fù)用
- 大圖使用漸進(jìn)式加載(先模糊后清晰)
- 配置錯(cuò)誤重試機(jī)制(指數(shù)退避)
- 大量圖片使用虛擬列表
- 重要圖片使用預(yù)加載
- 添加 loading 動(dòng)畫(huà)或骨架屏
不同場(chǎng)景的選擇
| 場(chǎng)景 | 技術(shù)方案 | 性能收益 |
|---|---|---|
| 普通圖片 | v-lazy 指令 + IntersectionObserver | 減少 80% 首屏請(qǐng)求 |
| 高質(zhì)量圖片 | LQIP + 平滑過(guò)渡 | 提升 60% 感知性能 |
| 圖片墻/畫(huà)廊 | 虛擬列表 + 按需加載 | 內(nèi)存占用減少 90% |
| 關(guān)鍵圖片 | 預(yù)加載 + 進(jìn)度條 | 用戶(hù)體驗(yàn)提升 |
| 輪播圖 | 預(yù)加載下一張 | 流暢切換 |
實(shí)施清單
- 懶加載基礎(chǔ):使用 IntersectionObserver 實(shí)現(xiàn) v-lazy 指令
- 漸進(jìn)式增強(qiáng):LQIP 模糊占位 + 高清圖過(guò)渡
- 錯(cuò)誤處理:重試機(jī)制 + 兜底圖
- 性能優(yōu)化:虛擬列表 + 緩沖區(qū)控制
- 用戶(hù)體驗(yàn):進(jìn)度反饋 + 平滑動(dòng)畫(huà)
最后的建議
圖片加載優(yōu)化不是單一技術(shù),而是一個(gè)系統(tǒng)工程:
- 網(wǎng)絡(luò)層面:使用 HTTP/2、CDN 加速
- 構(gòu)建層面:壓縮、格式轉(zhuǎn)換、雪碧圖
- 運(yùn)行時(shí)層面:懶加載、預(yù)加載、緩存
- 體驗(yàn)層面:進(jìn)度反饋、平滑過(guò)渡
結(jié)語(yǔ)
好的圖片加載策略應(yīng)該是無(wú)感知的。用戶(hù)不會(huì)注意到圖片是懶加載的,不會(huì)注意到有進(jìn)度條,他們只會(huì)感覺(jué)頁(yè)面"很快很流暢"。這才是優(yōu)化的最高境界。
以上就是深入探討Vue3組件中的圖片懶加載的實(shí)現(xiàn)原理與應(yīng)用的詳細(xì)內(nèi)容,更多關(guān)于Vue3圖片懶加載的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue :style設(shè)置背景圖片方式backgroundImage
這篇文章主要介紹了vue :style設(shè)置背景圖片方式backgroundImage,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-10-10
vue element 多圖片組合預(yù)覽的實(shí)現(xiàn)
本文主要介紹了vue element多圖片預(yù)覽實(shí)現(xiàn)的相關(guān)資料,最近的項(xiàng)目中有圖片預(yù)覽的場(chǎng)景,本文就來(lái)介紹一下如何使用,感興趣的可以了解一下2023-08-08
關(guān)于Element UI table 順序拖動(dòng)方式
這篇文章主要介紹了關(guān)于Element UI table 順序拖動(dòng)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-08-08
Vue3?+?elementplus實(shí)現(xiàn)表單驗(yàn)證+上傳圖片+?防止表單重復(fù)提交功能
這篇文章主要介紹了Vue3?+?elementplus?表單驗(yàn)證+上傳圖片+?防止表單重復(fù)提交,本文給大家展示效果圖和完整代碼,代碼簡(jiǎn)單易懂,對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-10-10
Vue CLI3.0中使用jQuery和Bootstrap的方法
這篇文章主要介紹了Vue CLI3.0中使用jQuery和Bootstrap的方法,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2019-02-02
vue?axios?form-data格式傳輸數(shù)據(jù)和文件方式
這篇文章主要介紹了vue?axios?form-data格式傳輸數(shù)據(jù)和文件方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-05-05
基于Vue3實(shí)現(xiàn)動(dòng)態(tài)表格的完整指南
解決VUE打包后與nginx代理出現(xiàn)加載速度超級(jí)慢的問(wèn)題
vue3?中使用?jsx?開(kāi)發(fā)的詳細(xì)過(guò)程

