在 vue 项目里,img 标签的图片加载和加载失败处理,看似是几行代码的事,真正做起来却能拖出一长串问题。我接手过一个后台管理项目,列表页一屏几十张缩略图,网络稍微抖动,浏览器默认的破图图标配上 alt 文字就糊满整屏,产品过来问“为什么有些位置一直转圈”,其实根本没有转圈,是图片没加载出来时留下的空白。后来我把项目里所有图片的加载状态重做了一遍,从最原始的 @load / @error 一路试到组件封装和自定义指令,踩的坑足够写一篇长文。这篇就把 vue 中 img 标签图片加载中、加载成功、加载失败这几个状态的处理思路、核心代码和避坑经验完整摊开讲清楚。不管你是刚接触 vue 的新手,还是已经写过几个项目、想把交互细节再抠一抠的老手,下面这些内容都能直接拿去用。
1. 先把问题拆开:img 标签到底有几种状态
很多人一上来就写 v-if 判断有没有 src,其实图片从设置地址到最终呈现在屏幕上,中间有好几种状态在切换,理不清这些状态,后面写出来的加载提示一定是错乱的。
1.1 三种基础状态,对应三种界面
一张图片在浏览器里的生命周期,最核心的三个节点是:还没有加载完成、加载成功、加载失败。对应的用户可见界面也应该是三套,而不是简单的“有图 / 没图”两套。
- 加载中:设置了 src,但浏览器还没拿到图,此时应该显示占位图、骨架屏或者旋转的加载指示,让用户知道“内容在路上”,而不是一片空白。
- 加载成功:图片解码完成并绘制到屏幕上,此时隐藏所有占位层,显示真实图片。
- 加载失败:网络错误、图片地址失效、跨域被拦、服务端返回非图片内容,都会走到失败分支,需要显示兜底图或错误提示。
这里有个容易被忽略的细节:加载中和失败如果共用一个占位元素,用户就分不清到底是“在加载”还是“已经挂了”。所以我习惯一开始就把状态定义成三个独立枚举值,比如loading、success、error,后续所有逻辑都围绕这个状态机走,代码会清爽很多。
1.2 状态没处理好会引出哪些“后遗症”
状态管理混乱带来的问题,往往不是开发阶段能发现的,而是上线后慢慢冒出来。我自己遇到过、也见过同事踩过的典型场景有这么几类:
第一类是白屏拖尾。图片还没加载完,父容器高度没撑起来,等图片突然加载成功,整个页面布局“跳”一下,用户体验很差。解决办法是给图片容器设定固定的宽高比,加载中就用这个占位框顶着。
第二类是破图死循环。图片加载失败触发了 onerror,代码里把 src 换成兜底图,结果兜底图也加载失败,又触发 onerror,无限循环刷请求,控制台刷得飞快。这个问题后面会单独讲怎么用标志位掐断。
第三类是缓存图片状态错乱。第二次进入页面,图片已经在浏览器缓存里,img 的 load 事件可能在 vue 绑定监听之前就触发了,导致状态一直停在“加载中”,明明图已经显示出来了。这个坑非常隐蔽,很多人查半天查不出来。
把这三类后遗症想在前面,你就知道为什么不能简单粗暴地写一个 v-if 了事。图片加载处理本质是一个和浏览器异步行为、缓存机制博弈的过程。
2. 方案选型:从手写事件到指令封装
明确了状态,接下来就是用什么方式去捕获这些状态变化。vue 里能用的手段有好几种,各有适用场景,选错了要么代码冗余,要么维护困难。
2.1 最直接的 @load 与 @error
在 vue 模板里,img 标签可以直接绑定原生事件:
<template> <img :src="imgUrl" alt="示例" @load="onLoad" @error="onError" /> </template> <script setup> import { ref } from 'vue' const imgUrl = ref('https://example.com/a.jpg') const status = ref('loading') function onLoad() { status.value = 'success' } function onError() { status.value = 'error' } </script>这套写法最直观,适合页面上只有一两张图、逻辑简单的场景。但它的缺点是状态和模板强耦合,每张图都要重复写一遍 @load、@error 和对应的处理函数。一旦项目里有几十处图片,维护成本立刻上来了,而且状态变量也要一处一处声明,很容易漏。
2.2 组件化封装:LoadingImage
把图片加载逻辑抽成一个通用组件,是我在稍大一点的项目里最常用的做法。组件内部维护自己的状态机,对外只暴露 src、alt、宽高这些属性,使用时就像用一个普通标签:
<LoadingImage :src="item.cover" alt="封面" :ratio="16 / 9" />组件负责处理加载中显示骨架、失败显示兜底,父组件完全不用关心这些细节。这种方案的优势是复用性极强,样式和行为集中在一处,改一次全局生效。缺点是灵活性略低,比如某些页面想要特殊的失败提示,就需要通过插槽来扩展。
2.3 自定义指令:v-img-status
如果不想引入组件、又想在模板里“一行搞定”,自定义指令是个不错的中间方案。指令可以直接作用在原生 img 上,挂载时自动绑定事件、切换类名:
<img v-img-status="{ src: imgUrl, errorClass: 'is-error' }" />指令内部通过操作 DOM 的 class 和属性来实现加载状态的可视化,特别适合那些已经写好了大量原生 img、只想补上状态提示的老项目。它的优势是侵入性小,劣势是状态不像组件那样容易在模板里读取和做条件渲染。
下面这张表是我针对三种方案做的横向对比,帮助你在不同场景下做决定:
| 方案 | 复用性 | 灵活性 | 改动成本 | 适用场景 |
|---|---|---|---|---|
| @load/@error 直写 | 低 | 高 | 低 | 图片少、逻辑简单 |
| 组件封装 | 高 | 中 | 中 | 中大型项目、统一交互 |
| 自定义指令 | 中 | 中 | 低 | 老项目改造、原生 img 多 |
选型没有绝对的对错,关键是看项目规模和团队习惯。我一般的原则是:新项目直接上组件,老项目补丁用指令,零散页面用直写。
3. 几个决定成败的细节
方案定了,真正的难点在细节。图片加载处理里最容易翻车的几个点,几乎都跟浏览器的缓存和异步行为有关,这些细节不处理好,前面的架构再漂亮也白搭。
3.1 load 事件不触发的缓存坑
这是新手最容易栽的地方。当图片已经在浏览器缓存中时,给 img 设置 src 后,浏览器可能同步就完成了加载,load 事件在 vue 完成事件监听绑定之前就触发了。结果就是图片明明显示出来了,你的状态却停在“加载中”,占位层一直盖在上面。
解决办法是利用 img 元素的complete属性和naturalWidth属性做兜底检测。complete为 true 表示加载已经结束,naturalWidth大于 0 表示这张图确实解码成功了,否则就是失败。
import { ref, onMounted, nextTick } from 'vue' const imgEl = ref(null) onMounted(() => { nextTick(() => { const el = imgEl.value if (el && el.complete) { if (el.naturalWidth > 0) { status.value = 'success' } else { status.value = 'error' } } }) })注意这里为什么要放在nextTick里。vue 的模板渲染和 DOM 挂载是有时序的,onMounted 触发时子元素通常已经挂载,但为了保险,等一下渲染队列更稳妥。这段兜底检测是解决缓存状态错乱的关键,我在三个项目里都靠它救过场。
提示:
complete为 true 时不一定代表成功,失败加载完成后 complete 也是 true,所以必须结合naturalWidth一起判断,这一点网上很多文章都写漏了。
3.2 error 的重试与兜底,别掉进死循环
图片加载失败的原因分两类:一类是地址本身有问题,重试多少次都没用;另一类是网络抖动、临时断开,稍等重试就能成功。我们的处理策略也应该分这两种来走。
对于临时性失败,可以做有限次数的重试,重试时给 URL 拼接时间戳参数绕开缓存:
function retrySrc(url, n) { const sep = url.includes('?') ? '&' : '?' return `${url}${sep}_retry=${n}&_t=${Date.now()}` }但重试必须有上限,否则遇到永久失效的地址就会无限刷请求。推荐的做法是设置一个retryCount,一般 1 到 2 次就够了,超过就切到兜底图。
兜底图这里就是前面提到的死循环陷阱。如果兜底图本身也加载失败,onerror 会再次触发,如果代码里没有标志位判断,就会无限循环。正确做法是用一个 attempts 计数或者isFallback标志,一旦进入兜底阶段就不再触发新的重试:
function onError() { if (isFallback.value) { // 兜底图也挂了,直接定格错误态,不再重试 status.value = 'error' return } if (attempt.value < maxRetry) { attempt.value++ src.value = retrySrc(originSrc.value, attempt.value) } else { isFallback.value = true src.value = fallbackImage } }3.3 超时控制:网络卡住时不能干等
浏览器对 img 的加载本身没有超时机制。也就是说,如果网络卡在半路,图片可能迟迟不触发 load 也不触发 error,你就会一直卡在“加载中”转圈。用户等三秒没反应,基本就流失了。
所以要自己加一层超时控制。思路是在开始加载时启动一个定时器,超过设定时间还没收到 load 就主动走失败逻辑:
let timer = null function startTimeout() { clearTimeout(timer) timer = setTimeout(() => { if (status.value === 'loading') { onError() } }, 8000) } function clearTimeout_() { if (timer) { clearTimeout(timer) timer = null } }超时时间我一般设 8 到 10 秒,太短会误杀慢网络下正常加载的图,太长又失去意义。这个 timeout 值最好做成可配置的 props,让不同场景按需调整。图片资源特别大的图集页面,可以适当放宽到 15 秒。
注意:超时后主动改 src 重试,浏览器会取消原来那个还在加载的请求,这是符合预期的行为,不用担心资源浪费。
3.4 动态切换 src 时的状态重置
列表页里点击切换缩略图是常见交互,img 的 src 变了,状态必须跟着重置回 loading,否则会残留上一张图的结果。这里要用 watch 监听 src 变化:
watch(() => props.src, () => { attempt.value = 0 isFallback.value = false status.value = 'loading' loadSrc() })如果不重置,会出现“新图还在加载但界面直接显示旧图的成功状态”这种诡异现象。另外,切换时记得清掉旧的定时器和旧的 IntersectionObserver 订阅,避免内存泄漏和错误回调。
4. 完整实现:一个能直接抄的图片组件
讲了这么多原理,直接给一个我实际项目里用的图片组件,结构清晰、逻辑完整,你可以直接复制到项目里改。
4.1 组件结构与状态设计
组件的核心是一个三态状态机:loading、success、error。对外暴露的 props 包括 src、alt、宽高比、重试次数、超时时间、是否懒加载。内部维护 attempt 计数、fallback 标志、定时器和观察器。整个组件只做一件事:把图片的加载过程可视化,把失败处理得当。
4.2 模板与样式
<template> <div class="loading-img" :style="{ paddingBottom: ratioPercent }"> <img v-show="status !== 'error'" ref="imgEl" class="loading-img__el" :src="realSrc" :alt="alt" :style="{ objectFit: fit, opacity: status === 'success' ? 1 : 0 }" @load="onLoad" @error="onError" /> <div v-if="status === 'loading'" class="loading-img__mask"> <span class="loading-img__spinner"></span> </div> <div v-else-if="status === 'error'" class="loading-img__mask loading-img__mask--error" > <span>图片加载失败</span> </div> </div> </template>用paddingBottom撑出固定宽高比,可以彻底解决加载完成后的布局跳动问题。这是一个非常实用的小技巧,比例由父级传入,比如 16:9 就传padding-bottom: 56.25%。
.loading-img { position: relative; width: 100%; height: 0; overflow: hidden; background: #f5f5f5; } .loading-img__el { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity .3s ease; } .loading-img__mask { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #999; font-size: 12px; } .loading-img__spinner { width: 20px; height: 20px; border: 2px solid #ddd; border-top-color: #888; border-radius: 50%; animation: li-spin .8s linear infinite; } @keyframes li-spin { to { transform: rotate(360deg); } }4.3 核心逻辑代码
<script setup> import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' const props = defineProps({ src: { type: String, default: '' }, alt: { type: String, default: '' }, ratio: { type: Number, default: 0 }, fit: { type: String, default: 'cover' }, retryCount: { type: Number, default: 2 }, timeout: { type: Number, default: 8000 }, lazy: { type: Boolean, default: false }, fallback: { type: String, default: '' } }) const emit = defineEmits(['load', 'error']) const imgEl = ref(null) const status = ref('loading') const realSrc = ref('') const attempt = ref(0) const isFallback = ref(false) let timer = null let observer = null const ratioPercent = computed(() => { return props.ratio > 0 ? `${(1 / props.ratio) * 100}%` : 'auto' }) function buildRetryUrl(url, n) { const sep = url.includes('?') ? '&' : '?' return `${url}${sep}_retry=${n}&_t=${Date.now()}` } function clearTimer() { if (timer) { clearTimeout(timer) timer = null } } function startTimer() { clearTimer() if (props.timeout > 0) { timer = setTimeout(() => { if (status.value === 'loading') onError() }, props.timeout) } } function onLoad() { clearTimer() status.value = 'success' emit('load') } function onError() { clearTimer() if (!isFallback.value && attempt.value < props.retryCount) { attempt.value++ realSrc.value = buildRetryUrl(props.src, attempt.value) status.value = 'loading' startTimer() return } if (props.fallback && !isFallback.value) { isFallback.value = true realSrc.value = props.fallback status.value = 'loading' startTimer() return } status.value = 'error' emit('error') } function loadRealSrc() { attempt.value = 0 isFallback.value = false status.value = 'loading' realSrc.value = props.src startTimer() } function setupLazy() { if (!props.lazy || !props.src) { if (props.src) loadRealSrc() return } if (!('IntersectionObserver' in window)) { loadRealSrc() return } observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { loadRealSrc() observer.disconnect() observer = null } }) }, { rootMargin: '120px' }) observer.observe(imgEl.value) } onMounted(() => { if (!props.src) { status.value = 'error' return } setupLazy() nextTick(() => { const el = imgEl.value if (el && el.complete) { el.naturalWidth > 0 ? onLoad() : onError() } }) }) watch(() => props.src, () => { if (!props.src) { status.value = 'error' return } loadRealSrc() }) onBeforeUnmount(() => { clearTimer() if (observer) { observer.disconnect() observer = null } }) </script>4.4 使用方式与常见扩展
用起来很直接:
<LoadingImage :src="item.cover" :ratio="16 / 9" alt="商品封面" :retry-count="1" :timeout="10000" lazy fallback="/static/default-cover.png" />几个扩展方向顺带说一下。第一,想自定义加载中或失败样式,可以把 mask 部分改成具名插槽<slot name="loading" />和<slot name="error" />,父组件传入自己的内容。第二,需要记录失败埋点,监听组件 emit 出来的 error 事件即可,不用改组件内部。第三,如果图片要支持点击预览,把点击事件透传到根元素就行。这种设计让组件保持了单一职责,又保留了足够的扩展空间。
5. 懒加载加骨架屏:把体验再抬一档
图片多的时候,光处理加载状态还不够,首屏一次性发几十个请求会把带宽挤爆,这时候懒加载就必须上场了。
5.1 用 IntersectionObserver 做懒加载
懒加载的核心是:图片还没进入可视区域时,不设置 src,等它快进入视口了再加载。传统做法监听 scroll 事件做计算,性能差、写法繁琐。现在标准做法是IntersectionObserver,浏览器原生支持,性能好得多。
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { loadRealSrc() observer.disconnect() } }) }, { rootMargin: '120px' }) observer.observe(imgEl.value)rootMargin设 120px 的意思是提前 120 像素就开始加载,这样用户滚动到图片位置时,图片往往已经加载好了,体验更顺滑。注意加载完成后要disconnect解除观察,避免持续占用资源。
5.2 骨架屏与加载态的结合
懒加载模式下,图片在进入视口前应该显示骨架屏,进入视口后显示加载指示,加载成功后淡入真图。这三个阶段用同一套状态机就能串起来,骨架屏本质上就是 loading 状态下的一种视觉表现。
我在实际项目里会给骨架屏加一个轻微的呼吸动画,比纯灰色块高级不少:
.loading-img__mask { background: linear-gradient(90deg, #f2f2f2 25%, #e8e8e8 37%, #f2f2f2 63%); background-size: 400% 100%; animation: skeleton 1.4s ease infinite; } @keyframes skeleton { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }需要提醒的是,懒加载和缓存检测的配合要小心。如果图片是懒加载的,nextTick里那次complete检测会因为 src 还没设置而永远返回 false,这时候状态会一直停在 loading。所以我上面的代码里,complete检测只在setupLazy已经真正加载时才做兜底,逻辑上要保证两者顺序正确。
6. 常见问题速查与避坑经验
把踩过的坑整理成一张速查表,遇到问题先对号入座,能省下大量排查时间。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 图片已显示但状态一直 loading | 缓存图片 load 事件早于监听绑定 | 用 complete + naturalWidth 兜底检测 |
| 控制台疯狂报错、请求刷屏 | 兜底图也失败导致 onerror 死循环 | 加 isFallback 标志,进入兜底后不再重试 |
| 加载中一直转圈不结束 | 网络卡住无 load 也无 error | 加超时定时器主动走失败逻辑 |
| 切换图片后状态错乱 | src 变化没重置状态 | watch src 重置 attempt 和 status |
| 加载完成后页面“跳”一下 | 容器高度未提前占位 | 用 paddingBottom 撑固定宽高比 |
| 懒加载图片永远不加载 | 观察器未生效或 src 未真正赋值 | 检查 IntersectionObserver 支持与 rootMargin |
| 组件卸载后仍报错 | 定时器或观察器未清理 | onBeforeUnmount 统一清理 |
除了表格里的,再补几条纯经验层面的东西,这些是文档里不会写、但实际开发中很要命的点。
第一条,别在 onerror 里直接改同一个 img 的 src 然后指望它自愈。如果你的重试逻辑和失败兜底都写在同一个 onerror 回调里,一定要有明确的计数或标志位,否则某些极端情况下事件会连环触发。我的写法是重试和兜底分成两个阶段,边界清晰。
第二条,留意跨域图片和 canvas 操作。如果图片后续要用 canvas 做裁剪或导出,img 必须设置crossorigin="anonymous",并且服务端要返回对应的 CORS 头,否则 canvas 会被污染,导出时报安全错误。这和加载状态是两回事,但经常一起出现。
第三条,图片地址不要轻易加时间戳。前面重试时加了_t参数绕过缓存,这是有代价的——每次都会重新请求,完全跳过浏览器缓存。所以时间戳只应该出现在重试路径上,正常首次加载的地址保持干净。
第四条,测试时手动造失败。把 src 改成一个不存在的域名,或者用浏览器开发者工具把网络限速到最慢,能很快暴露出超时逻辑和兜底逻辑的问题。我最开始在本地测都正常,一上弱网环境全露馅,就是因为没做这一步。
第五条,SSR 项目要防 window 未定义。IntersectionObserver 是浏览器 API,在服务端渲染时不存在,直接调用会报错。所以使用前要判断typeof window !== 'undefined',或者把懒加载逻辑放到 onMounted 里,因为 onMounted 只在客户端执行。
我个人在实际操作中的体会是,图片加载这块最难的从来不是写代码,而是把浏览器的各种异步边界情况想周全。缓存命中、网络超时、兜底失败、组件销毁,每一个都可能是线上 bug 的来源。把这些边界枚举出来,用状态机统一管理,再用组件把复杂度封装起来,剩下的就是复制粘贴的活了。这套方案我在后台系统、电商列表、内容社区三种场景里都跑过,稳定性和可维护性都不错,你要是正在被破图和转圈困扰,不妨照着搭一遍,改起来会比重写一遍省心得多。