学完这篇你能得到一个可以直接复制进项目的 Vue 3 图片懒加载组件:它基于原生 Intersection Observer API,单例复用 observer,自带占位、淡入和失败回退,列表里几十上百张图也不会卡。
第一步:先想清楚要观察什么
懒加载的核心不是"图片加载",而是"元素是否进入了视口"。Intersection Observer 帮你做这件事:把一个元素交给 observer 观察,当它进入视口时浏览器回调你,你不用监听 scroll、不用手算 getBoundingClientRect。
最简用法长这样:
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 元素进入视口了,去做加载
}
})
})
io.observe(document.querySelector('.lazy'))
先明确一点:observe() 之后如果这个元素一直不出来,回调永远不会触发,也不会消耗性能,这就是它比 scroll 监听干净的地方。
第二步:写一个单例 observer
每个图片组件都 new 一个 observer 是常见错误。几十张图就是几十个观察者实例,内存和回调开销都会上去。正确做法是按 rootMargin 分组,同组的共用一个实例。
新建 utils/lazyObserver.js:
const pools = new Map()
function createPool(rootMargin) {
const callbacks = new WeakMap()
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const cb = callbacks.get(entry.target)
if (cb) {
callbacks.delete(entry.target)
io.unobserve(entry.target) // 加载一次就够,立刻停止观察
cb()
}
}
},
{ rootMargin, threshold: 0.01 }
)
return {
watch(el, cb) {
callbacks.set(el, cb)
io.observe(el)
},
unwatch(el) {
callbacks.delete(el)
io.unobserve(el)
},
}
}
export function getPool(rootMargin = '200px 0px') {
if (!pools.has(rootMargin)) pools.set(rootMargin, createPool(rootMargin))
return pools.get(rootMargin)
}
rootMargin 是提前量:设成 200px 意味着元素距离视口还有 200px 时就开始加载,用户滚到跟前图已经好了,不会有白块。
注意:rootMargin 加得太大,首屏就会把所有图都加载掉,等于没懒加载;太小(比如默认 0)用户滚得快就会看到空位。200px~400px 是个比较稳的区间,长列表可以适当放大。
第三步:封装组件 LazyImage.vue
<template>
<div class="lazy-wrap" :style="{ aspectRatio: ratio }">
<img
ref="imgEl"
:src="currentSrc"
:alt="alt"
:class="['lazy-img', state]"
@load="onLoad"
@error="onError"
/>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { getPool } from '@/utils/lazyObserver'
const props = defineProps({
src: { type: String, required: true },
alt: { type: String, default: '' },
ratio: { type: String, default: '16 / 9' },
rootMargin: { type: String, default: '200px 0px' },
placeholder: {
type: String,
default: 'data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E',
},
})
const emit = defineEmits(['load', 'error'])
const imgEl = ref(null)
const currentSrc = ref(props.placeholder)
const state = ref('idle') // idle | loading | loaded | error
let pool = null
function loadImage(url) {
state.value = 'loading'
const img = new Image()
img.onload = () => {
currentSrc.value = url
state.value = 'loaded'
emit('load', url)
}
img.onerror = () => {
state.value = 'error'
emit('error', url)
}
img.src = url
}
function onLoad() {
if (state.value === 'loading') return
}
function onError() {
state.value = 'error'
}
onMounted(() => {
if (!('IntersectionObserver' in window)) {
loadImage(props.src) // 老浏览器兜底:直接加载
return
}
pool = getPool(props.rootMargin)
pool.watch(imgEl.value, () => loadImage(props.src))
})
onBeforeUnmount(() => {
pool && pool.unwatch(imgEl.value)
})
// 列表复用组件时 src 会变,必须重置状态
watch(
() => props.src,
(val) => {
state.value = 'idle'
currentSrc.value = props.placeholder
if (!('IntersectionObserver' in window)) return loadImage(val)
pool.watch(imgEl.value, () => loadImage(val))
}
)
</script>
<style scoped>
.lazy-wrap {
position: relative;
width: 100%;
overflow: hidden;
background: #f2f3f5;
}
.lazy-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease;
}
.lazy-img.loaded {
opacity: 1;
}
</style>
外层包一个 aspect-ratio 容器是关键:图片还没加载时高度就已经占住了,加载完不会把下面的内容顶下去。
注意:aspect-ratio 必须配合容器已经有宽度才生效。如果父元素是 flex 且没给宽度,容器会塌成 0 高,图片永远不进入视口,看起来就是"整页都不加载"。
第四步:在页面里使用
<LazyImage
v-for="item in list"
:key="item.id"
:src="item.cover"
:alt="item.title"
ratio="4 / 3"
@load="onItemLoad(item.id)"
/>
列表数据量大时,ratio 尽量写死,不要用 JS 每张算高度。
第五步:几个必须处理的坑
v-for 必须用稳定 key(如 item.id)。用 index 会在数据插入后让组件复用到错的 URL,配合 watch(src) 重置状态才能避免串图。
display: none 或父级隐藏的元素不会触发回调,比如折叠面板里的图片。这种场景可以在展开时手动调用一次 loadImage。
- SSR 下
IntersectionObserver 不存在,所以判断写在 onMounted 里,别写在 setup 顶层。
- 背景图不能懒加载。
background-image 没有可观察的 DOM 元素语义,用 <img> 或者额外套一层观察 div 再切换 class。
小结:
- 懒加载观察的是容器元素,不是图片本身;
- observer 按
rootMargin 分组复用,加载成功后立刻 unobserve;
- 组件卸载时一定
unwatch,否则列表滚动会造成内存堆积;
- 用
aspect-ratio 占位,避免图片加载后页面跳动;
watch 监听 src 变化并重置状态,是列表复用的前提;
- 老浏览器和不支持的环境留一条"直接加载"的兜底路径。