一、前言
图片是大头资源:商品列表 200 张图,用户首屏只看得到 3 张,剩下 197 张的下载纯属浪费。本篇三连招:图片懒加载 (进视口才加载)、骨架屏 (加载前的占位动画)、加载失败兜底,全部可复制。
二、懒加载的三种姿势
| 方案 | 原理 | 适用 |
|---|---|---|
原生 loading="lazy" |
浏览器自动,一行搞定 | ★ 简单场景首选 |
| 自定义指令 v-lazy | IntersectionObserver 监听进视口 | 需要自定义加载态/渐显动画 |
| IntersectionObserver 手写 | 指令的底层 | 理解原理 / 特殊需求 |
原生方案演示:
html
<!-- 浏览器自动判断:进视口附近才发请求 -->
<img src="big.jpg" loading="lazy" alt="商品图" />
能用原生的先用原生;需要"加载前灰底 + 加载后渐显 + 失败兜底"完整体验,就上自定义指令。
三、自定义指令 v-lazy:完整实现
034 篇的自定义指令 + IntersectionObserver 组合:
ts
// src/directives/lazy.ts
import type { Directive } from 'vue'
const observerMap = new WeakMap<Element, IntersectionObserver>() // 便于清理
export const vLazy: Directive<HTMLImageElement, string> = {
mounted(el, binding) {
// 1. 真实地址暂存,src 先放占位图(1px 透明图,不发网络请求)
const realSrc = binding.value
el.src =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4='
el.style.backgroundColor = 'var(--el-fill-color-light)' // 灰底占位
// 2. 进视口才真正加载
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
el.src = realSrc // ★ 进入视口:换真实地址
el.onload = () => el.style.opacity = '1' // 加载完渐显
el.style.transition = 'opacity 0.3s'
el.style.opacity = '0'
io.unobserve(el) // 只需要触发一次
observerMap.delete(el)
})
}, { rootMargin: '200px' }) // ★ 提前 200px 预加载,滚动更顺滑
io.observe(el)
observerMap.set(el, io)
},
// 3. 组件卸载时断开监听(防内存泄漏,037 篇同款约定)
unmounted(el) {
observerMap.get(el)?.disconnect()
observerMap.delete(el)
}
}
ts
// src/main.ts
import { vLazy } from '@/directives/lazy'
app.directive('lazy', vLazy)
vue
<!-- 组件里:src 属性换成 v-lazy 指令 -->
<img
v-for="g in goodsList"
:key="g.id"
v-lazy="g.imgUrl"
style="width: 100%; aspect-ratio: 4 / 3; object-fit: cover"
/>
四、骨架屏:el-skeleton 占位
图片加载慢的"等待期"用骨架屏安抚用户(比转圈体验好:提前告知内容形状):
vue
<template>
<!-- is-loading 为 true 时显示骨架,false 显示真实内容 -->
<el-skeleton :loading="loading" animated :count="3">
<template #template>
<div style="display: flex; gap: 12px; margin-bottom: 12px">
<el-skeleton-item variant="image" style="width: 80px; height: 60px" />
<div style="flex: 1">
<el-skeleton-item variant="text" style="width: 60%" />
<el-skeleton-item variant="text" style="width: 40%; margin-top: 8px" />
</div>
</div>
</template>
<!-- #default:真实内容 -->
<template #default>
<div v-for="g in goodsList" :key="g.id" style="display: flex; gap: 12px; margin-bottom: 12px">
<img v-lazy="g.imgUrl" style="width: 80px; height: 60px; object-fit: cover" />
<div>
<p>{{ g.name }}</p>
<p>¥{{ g.price }}</p>
</div>
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getGoodsList } from '@/api/goods'
import type { GoodsItem } from '@/api/goods'
const loading = ref(true)
const goodsList = ref<GoodsItem[]>([])
onMounted(async () => {
const res = await getGoodsList() // 062 篇的 api
goodsList.value = res.data.list
loading.value = false // ★ 数据到了才关骨架
})
</script>
【截图位置:弱网下先看到灰色骨架动画 → 数据到达后骨架替换为真实列表】
五、失败兜底:加载失败显示占位图
ts
// 指令里补失败处理(挂在 el 上即可)
el.onerror = () => {
el.src = '/img/fallback.png' // 本地默认占位图(logo 灰图)
el.style.opacity = '1'
}
原则:每张图都有"加载中"(骨架/灰底)和"加载失败"(占位图)两个归宿,用户永远不会看到"裂图"图标。
六、踩坑记录
- 占位图写成大图:占位图本身几 MB 就本末倒置------用 1px 透明 base64 或纯灰底色
- 忘了 unobserve:一直监听,列表长时 observer 堆积------加载后立即 unobserve + 移除引用
- unmounted 不清理:路由切换后旧元素还被监听(内存泄漏)------WeakMap + disconnect(037 篇事件清理同款)
- 图片没有固定尺寸:懒加载后图片撑开布局,滚动位置跳动(CLS)------固定宽高或 aspect-ratio 占位
- 骨架屏一直转:接口报错时 loading 忘了关------try/finally 里关 loading
- rootMargin 不加:进入视口才加载,滚动快时总慢半拍------提前 200~500px 预加载
七、今日小结
- 三种姿势:原生 loading="lazy" 优先;完整体验用 v-lazy 指令
- 指令核心:IntersectionObserver 进视口换 src + rootMargin 提前量 + unmounted 清理
- 骨架屏:el-skeleton :loading 切换骨架/内容,形状贴近真实内容
- 双兜底:加载中(灰底/骨架)+ 失败(占位图),消灭裂图
下篇预告
搜索框每敲一个字发一次请求、提交按钮狂点发 N 个请求------服务端和用户都在崩溃。下一篇 076 防抖节流在 Vue3 中的正确使用:038 篇封装的工具终于大规模实战。