1. 一个被低估的老话题:图片加载状态到底值不值得认真做
刚入行那会儿,我对<img>标签的理解就是“写个 src 就完事了”。直到某个项目上线后,运营反馈说商品列表里有一批图总是空白,用户以为商品下架了,客服电话都被打爆。我打开控制台一看,图片 404,浏览器就留了个破图标或者干脆一片白,页面上没有任何提示。那次事故让我意识到,图片加载这件事看着简单,背后其实分三个状态:加载中、加载成功、加载失败,而绝大多数人只处理了“成功”这一种。
vue里处理<img>的加载与失败,本质上就是围绕浏览器给图片元素提供的两个原生事件:load和error。load在图片资源成功解码并可以渲染时触发,error在网络请求失败、资源不存在、格式不支持、跨域受限等情况下触发。听起来就两个事件,但真正落地到业务里,问题全是细节:骨架屏什么时候显示、失败后是重试还是占位、重试几次、重试间隔多久、路由切换时组件销毁了回调还在不在、图片加载完了要不要做淡入、v-for列表里几百张图同时失败会不会把主线程打满。这些问题不解决,代码写出来就是能用但经不起推敲。
这篇文章我想聊的就是把这件事做扎实的完整方案。适合谁看?如果你正在写vue项目,列表页有大量图片,或者你做的是后台管理系统、电商、内容社区这类图片密集的场景,那这套东西你迟早要用。新手能拿到可以直接复制的组件和指令,老手可以看看我在状态管理、缓存、性能取舍上的思路,也许能补上几个自己没注意的盲区。我不打算只给你一段@error代码就完事,而是把“为什么这么设计”讲透,这样你遇到变体需求时自己能改。
先给个最朴素的版本建立直觉。原生写法里,我们会在img上监听两个事件,用一个变量记录状态:
<img :src="imgUrl" @load="onLoad" @error="onError" />data() { return { status: 'loading' } // loading | success | error }, methods: { onLoad() { this.status = 'success' }, onError() { this.status = 'error' } }这三行几乎就是全部核心逻辑了。但请注意一个关键点:error事件只会在“一次加载尝试”里触发一次。如果你不改变src的值,浏览器不会自动重试,你就算在onError里再写一遍this.imgUrl = this.imgUrl,因为值没变,vue不会触发 DOM 更新,图片也不会重新请求。这是新手最容易卡住的地方,后面讲重试机制时会重点展开。理解了这一点,你就明白为什么“重试”必须配合“改变 URL”或者“强制重新挂载元素”来做。
2. 方案选型:组件、指令还是自己封装,各有什么坑
vue生态里处理图片加载,主流有三条路:封装成组件、封装成自定义指令、直接用第三方库。这三条路没有绝对优劣,关键看你的项目结构和复用范围。我三个都用过,也都在真实项目里踩过坑,下面把取舍逻辑摊开讲。
2.1 封装组件方案:可控性最强,适合复杂状态
组件方案的核心思路是把<img>包一层,对外只暴露src,内部自己管理loading / success / error三个状态,并根据状态渲染骨架、占位图或者真实图片。它的最大优势是模板可控:加载中你想放骨架屏就放骨架屏,失败想放一个带“点击重试”按钮的占位块也行,因为你完全掌握了渲染结构。
我一般这么设计组件的 props:src(必填)、alt、placeholder(加载中占位图,可选)、errorImage(失败兜底图)、fit(对应object-fit)、lazy(是否开启懒加载)、retryCount(失败重试次数)。事件方面对外抛load和error,让父组件有机会埋点上报。这样设计的好处是,父组件不用关心内部怎么实现,只管传数据和监听结果。
组件方案适合什么场景?我总结是图片状态复杂、需要统一交互的地方。比如电商详情页,加载中要骨架,失败要能重试,还要上报错误率给监控系统,这种就必须用组件。缺点是每个<img>都变成一个组件实例,如果列表里有大几百张图,组件实例本身也有开销。这个开销到底有多大,后面性能章节我会给实测数据。
2.2 自定义指令方案:侵入性最小,适合存量改造
如果你维护的是一个已经写了很多<img>的老项目,一个个改成组件成本太高,这时候自定义指令就香了。指令的思路是:通过v-img这样的指令,在元素挂载时绑定load和error监听,加载失败时直接修改元素的src为兜底图,同时通过binding.value或者指令修饰符接收配置。
指令方案最大的优点是改动面小:原来写<img :src="x">,现在改成<img v-img="x">就行,模板结构几乎不动。它天然适合做“失败兜底图”和“懒加载”这种通用能力。但它的短板也明显:指令操作的是真实 DOM,你没法在指令里优雅地渲染一个“重试按钮”或者复杂的骨架结构,因为指令只能改现有元素的属性。想加复杂 UI,就得手动document.createElement,那还不如写组件。
所以我的经验是:兜底图、懒加载这类“纯行为增强”用指令;状态 UI、重试交互这类“需要渲染结构”的用组件。两者不冲突,甚至可以共存。
2.3 第三方库方案:省事但要知道它做了什么
社区里有一些现成的图片懒加载和状态处理库,用起来确实快。但我想提醒一句:用库之前一定要搞清楚它替换了原生的什么行为。有些库为了兼容性,会给<img>换src属性、用IntersectionObserver做懒加载、加一层>const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const el = entry.target const src = el.dataset.src if (src) { el.src = src el.removeAttribute('data-src') } // 一旦进入视口就取消观察,图片不需要反复监听 observer.unobserve(el) } }) }, { rootMargin: '200px 0px', // 提前 200px 预加载,减少用户等待 threshold: 0.01 }) // 注册元素 observer.observe(imgElement)
rootMargin这个参数值得多说一句。默认是0px,意味着元素真正进入视口才开始加载,用户滚动快的时候会看到明显的“白块逐个冒出来”。我一般设成200px到300px,让图片提前一点开始请求,滚动体验会顺滑很多。但这个值不是越大越好,设成1000px就等于列表一进页面全部开始加载,懒加载的意义就没了。根据你的图片平均大小和用户滚动速度来调,几百 KB 的大图可以给大一点,几十 KB 的小图标给小一点甚至不懒加载。
3. 核心细节深挖:从事件时机到状态机的设计
要把这套东西写好,光知道load和error不够,得把几个容易出错的细节说透。
3.1 load 和 error 的触发时机,以及一个隐藏的坑
load事件在图片资源完全加载并解码后触发,此时图片可以安全渲染。error事件则在以下情况触发:网络请求失败(DNS 解析失败、连接超时)、HTTP 状态码不是 2xx(404、403、500 等)、资源不是有效图片格式(比如把一段 HTML 当图片返回)、图片解码失败、跨域图片在 canvas 场景下受污染等。
这里有个隐藏的坑:缓存图片的事件可能同步触发,导致你绑定监听时事件已经过去了。具体来说,如果一张图片已经在浏览器缓存里,当你设置src后,某些浏览器会立即触发load,而此时如果你是在mounted之后才绑定监听,就可能错过这个事件,状态永远停在loading。原生<img>有个complete属性可以判断,如果img.complete === true且naturalWidth > 0,说明图片已经加载完成。在组件里我一般会在mounted之后补一次检查:
mounted() { const img = this.$refs.imgEl if (img && img.complete) { // 缓存的图片可能没触发 load,这里补一次 if (img.naturalWidth > 0) { this.status = 'success' } else { this.status = 'error' } } }naturalWidth是个好东西,它是图片的原始宽度,只有真正解码成功才是正数,加载失败时是 0。用complete + naturalWidth组合判断,基本能覆盖缓存场景。
3.2 状态机设计:为什么不能只有两个状态
很多人写的时候只用一个hasError布尔值,加载中默认什么样式都没有。这样写的问题是,你没法区分“还没开始加载”“正在加载”“加载完成”这三种状态,骨架屏和淡入效果都没法做。
我建议用明确的三态甚至四态:idle(未开始)、loading、success、error。四态里多出来的idle在懒加载场景下有用,图片还没进视口就是idle,进了视口变loading,请求回来变success或error。状态流转是这样的:
| 当前状态 | 触发事件 | 下一状态 | 说明 |
|---|---|---|---|
| idle | 进入视口 / src 变化 | loading | 开始请求图片 |
| loading | load | success | 加载成功 |
| loading | error | error | 一次尝试失败 |
| error | 点击重试 / 自动重试 | loading | 重置状态重新请求 |
| success | src 变化 | loading | 换了新图,重新走流程 |
这个状态机的价值在于,它把“用户看到的界面”和“事件流”解耦了。界面只认状态,逻辑只改状态,两边互不干扰。我在中大型项目里都坚持这套,因为后面加需求(比如加个“加载失败重试 3 次后再显示兜底”)时,只需要在状态流转里加判断,UI 层完全不用动。
注意:
src变化时一定要把状态重置回loading。这是vue里非常容易漏的一点,因为<img>的src变了,浏览器会重新请求,但你组件里的status还是上一张图的success,结果新图还没加载出来,界面就已经显示成功态了。用watch监听src是标准做法。
3.3 重试机制:为什么你写的重试不生效
前面提过,error只在一次加载尝试里触发一次,不改src就不会重试。所以重试的核心是每次重试都让 URL 不同。最常见的做法是加时间戳查询参数:
retry() { if (this.retried >= this.retryCount) { this.status = 'error' // 超过重试次数,走最终兜底 return } this.retried++ this.status = 'loading' const separator = this.src.includes('?') ? '&' : '?' // 加时间戳让 URL 变化,触发浏览器重新请求 this.realSrc = `${this.src}${separator}_t=${Date.now()}` }这里有个细节要提醒:加时间戳会绕过浏览器缓存,每次都走网络请求。如果你的失败只是偶发的网络抖动,这样重试合理;但如果资源本身就 404,重试 3 次就是白白浪费 3 次请求。所以更聪明的做法是结合错误类型判断,但这个浏览器端拿不到足够信息(error事件不告诉你失败原因),能做到的就是限制重试次数 + 加退避间隔。我的经验值是重试 2 到 3 次,间隔 300ms、800ms 递增,超过就显示兜底图。再多就影响体验了,用户盯着一个一直转圈的图会很烦躁。
另一个实现思路是给图片元素加 key,通过强制重新创建 DOM 来重试:
<img :key="retryKey" :src="src" @load="onLoad" @error="onError" />retry() { this.retryKey++ // 强制重建 img 元素,等同于重新加载 }这种方式绕过了 URL 缓存的干扰,因为 DOM 是全新的。缺点是组件实例和 DOM 节点会重新创建,有轻微开销。我一般用时间戳方案,简单直接,够用。
4. 完整组件实现:从骨架到重试一步步搭起来
理论讲够了,直接上能跑的完整实现。这是一个ImgLoader组件,功能覆盖:三态渲染、骨架占位、失败兜底、自动重试、懒加载、加载成功淡入。
4.1 组件模板与状态结构
<template> <div class="img-loader" :style="wrapperStyle"> <!-- 加载中或结束时显示的占位/兜底 --> <img v-if="showPlaceholder" class="img-loader__placeholder" :src="currentPlaceholder" alt="" aria-hidden="true" /> <!-- 真实图片,始终在 DOM 中,靠透明度控制显隐 --> <img ref="realImg" class="img-loader__real" :class="{ 'is-loaded': status === 'success' }" :src="renderSrc" :alt="alt" :style="{ objectFit: fit }" @load="handleLoad" @error="handleError" /> <!-- 加载失败且超过重试次数,显示重试入口 --> <div v-if="status === 'error' && manualRetry" class="img-loader__retry" @click="handleRetry"> <span>加载失败,点击重试</span> </div> </div> </template>export default { name: 'ImgLoader', props: { src: { type: String, required: true }, alt: { type: String, default: '' }, fit: { type: String, default: 'cover' }, loadingImage: { type: String, default: '' }, errorImage: { type: String, default: '' }, autoRetry: { type: Boolean, default: true }, retryCount: { type: Number, default: 2 }, manualRetry: { type: Boolean, default: false }, lazy: { type: Boolean, default: false } }, data() { return { status: 'idle', // idle | loading | success | error retried: 0, realSrc: '', observer: null } }, computed: { renderSrc() { return this.realSrc || this.src }, showPlaceholder() { return this.status !== 'success' }, currentPlaceholder() { return this.status === 'error' ? this.errorImage : this.loadingImage }, wrapperStyle() { return { position: 'relative', overflow: 'hidden' } } } }这里我选择让真实<img>始终存在于 DOM 中,而不是v-if切换。原因有两个:一是v-if会销毁重建元素,导致事件监听反复绑定,容易出状态不同步的 bug;二是始终存在可以让淡入过渡更自然。占位图叠在底层,真实图靠opacity从 0 变 1,视觉上就是平滑过渡。
4.2 生命周期与事件处理的配合
mounted() { if (this.lazy) { this.initObserver() } else { this.startLoad() } }, beforeDestroy() { // 组件销毁时务必断开观察,否则内存泄漏 if (this.observer) { this.observer.disconnect() this.observer = null } }, watch: { src() { // src 变化,重置状态重新走流程 this.retried = 0 this.realSrc = '' if (this.lazy) { this.status = 'idle' this.initObserver() } else { this.startLoad() } } }, methods: { startLoad() { this.status = 'loading' }, initObserver() { if (!this.observer) { this.observer = new IntersectionObserver(this.onIntersect, { rootMargin: '200px 0px', threshold: 0.01 }) } this.$nextTick(() => { this.observer.observe(this.$refs.realImg) }) }, onIntersect(entries) { entries.forEach((entry) => { if (entry.isIntersecting) { this.startLoad() this.observer.unobserve(entry.target) } }) }, handleLoad() { // 补一次缓存图片的判断 this.status = 'success' this.$emit('load') }, handleError() { const img = this.$refs.realImg // complete 为 true 且 naturalWidth 为 0 才是真失败 if (img && img.complete && img.naturalWidth > 0) { this.status = 'success' return } this.$emit('error', this.src) if (this.autoRetry && this.retried < this.retryCount) { this.scheduleRetry() } else { this.status = 'error' } }, scheduleRetry() { const delay = 300 * Math.pow(2, this.retried) // 300ms, 600ms, 1200ms this.retried++ setTimeout(() => { const sep = this.src.includes('?') ? '&' : '?' this.realSrc = `${this.src}${sep}_retry=${this.retried}&_t=${Date.now()}` this.status = 'loading' }, delay) }, handleRetry() { this.retried = 0 const sep = this.src.includes('?') ? '&' : '?' this.realSrc = `${this.src}${sep}_t=${Date.now()}` this.status = 'loading' } }这段代码有几个设计点值得展开。重试间隔用的是指数退避(300ms、600ms、1200ms),而不是固定间隔。原因是如果服务器短暂抖动,第一次重试紧接着就能成功,不需要等太久;如果服务器压力大,越往后间隔越长,避免雪崩式重试给它添乱。这个思路借鉴了网络请求重试的通用实践,图片这种静态资源同样适用。
handleError里那段complete && naturalWidth > 0的判断,是为了防止一种边缘情况:图片已经缓存并渲染成功,但因为某些扩展或者浏览器行为又触发了error。加上这个校验,能避免把已经显示好的图错误地切成兜底图。
4.3 样式与淡入过渡
.img-loader { position: relative; overflow: hidden; background-color: #f5f5f5; /* 骨架底色 */ } .img-loader__placeholder { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; filter: blur(2px); /* 轻微模糊,视觉上更像骨架 */ } .img-loader__real { position: relative; display: block; width: 100%; height: 100%; opacity: 0; transition: opacity 0.3s ease; } .img-loader__real.is-loaded { opacity: 1; } .img-loader__retry { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 14px; cursor: pointer; z-index: 2; }淡入的核心就是opacity过渡。这里有个性能细节:opacity和transform是合成属性,不触发重排重绘,用它们做动画很便宜。如果用display或者改变width/height做过渡,会触发layout,图片多的时候页面会卡。这是我在做长列表图片淡入时反复验证过的,opacity方案滚动帧率明显更稳。
注意:占位图的
blur(2px)滤镜要慎用。filter在某些低端机上会触发额外的合成层,如果页面上同时有大量带模糊的占位图(比如 100 张),会有明显性能下降。我一般只在首屏或者单张详情图用模糊,列表里直接用纯色骨架更实在。
5. 常见问题排查实录与实战避坑清单
真到项目里,bug 往往不出现在主流程,而出在边界。下面这些坑我基本都踩过,整理成速查表加详细说明,希望能帮你少走弯路。
5.1 问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 图片加载成功但状态一直 loading | 缓存图片事件早于监听绑定 | 检查img.complete | 补mounted后的 complete 校验 |
| 失败后重试没反应 | src 未变化,vue 不更新 DOM | 检查重试代码是否改 URL | 加时间戳或改 key |
| 切路由后报错“setState on destroyed” | 异步回调在组件销毁后执行 | 检查 setTimeout / observer | beforeDestroy里清理定时器和 observer |
| 图片全部挤在一起变形 | 容器没有固定尺寸 | 检查父容器布局 | 给容器显式宽高或宽高比 |
| 大量图片同时失败页面卡死 | 兜底图同时请求 + 同步 setState | 看 Network 和 Performance | 兜底图加缓存 + 失败状态合并 |
| 跨域图片在某些场景加载失败 | 服务器未返回跨域头 | 看 Console 跨域报错 | 配置图片服务器 CORS |
| 图片模糊 | 被容器拉伸或 DPR 不匹配 | 检查尺寸和srcset | 用适配分辨率或srcset |
5.2 内存泄漏:被忽视的定时器和观察者
我见过最隐蔽的 bug,是用户在一个列表页和详情页之间快速来回切,切十几次后页面开始卡,控制台报Cannot read property of undefined。查下来是重试的setTimeout没有被清理,组件销毁后回调还在跑,试图去改一个已经不存在的组件状态。
解决方式是在beforeDestroy里记录所有定时器 id 并清除:
data() { return { retryTimer: null } }, beforeDestroy() { if (this.retryTimer) { clearTimeout(this.retryTimer) this.retryTimer = null } if (this.observer) { this.observer.disconnect() this.observer = null } }IntersectionObserver同理,它是挂在浏览器上的,不主动disconnect就会一直持有对 DOM 的引用,造成泄漏。这个习惯一定要养成:凡是异步、凡是观察者,组件销毁时都要有对应的清理动作。
5.3 大量图片失败时的性能取舍
假设一个商品列表 200 张图,因为 CDN 挂了全部失败。会发生什么?200 个error事件几乎同时触发,每个都去加载兜底图,如果兜底图是网络图且没缓存,就是 200 个并发请求;每个error处理里还调用了this.status = 'error',触发 200 次组件更新。vue的更新是批量的还好,但如果你的兜底逻辑里有复杂计算,主线程会被拖住,用户感觉页面卡住不动。
我的处理办法有三个。第一,兜底图尽量用本地内联的 data URI 或者极小的本地图片,避免再走网络。第二,失败重试和状态更新做节流,或者干脆用一个全局计数器限制同时重试的数量。第三,给图片加loading="lazy"(原生懒加载),至少非视口内的图片不会同时发起请求。这三招组合下来,即使 CDN 挂了,页面也不会崩,只是显示一排兜底图而已。
// 全局重试并发控制的小思路 const retryQueue = [] let activeRetries = 0 const MAX_CONCURRENT = 5 function enqueueRetry(task) { if (activeRetries < MAX_CONCURRENT) { activeRetries++ task().finally(() => { activeRetries-- const next = retryQueue.shift() if (next) enqueueRetry(next) }) } else { retryQueue.push(task) } }注意:这个并发控制在图片量特别大的场景才有必要,普通页面别过度设计。我一般只有在“一个页面可能同时出现 50 张以上图片”时才上这套,否则纯属给自己加复杂度。
5.4 几个反直觉的细节
第一,alt属性在图片加载失败时会显示出来。如果你给了alt="商品图片",失败时那个破图标旁边就会出现这行文字,配合你的失败兜底图会显得很乱。我的做法是,真实<img>保留有意义的alt(利于无障碍和 SEO),但失败兜底图覆盖在上面时确保它完全遮住,或者失败时把真实图的opacity设为 0。
第二,object-fit不会自动生效,容器必须有明确尺寸。很多人加了object-fit: cover发现没用,是因为父容器高度是内容撑开的,没有约束。必须给容器一个显式的宽高或者宽高比(用aspect-ratio属性),object-fit才有意义。
第三,vue的@error在某些情况下会一次触发两次。这通常发生在图片先失败、你改了 src、新 src 又失败的情况下,两次事件分别对应两次尝试,不是 bug。调试时打个日志看清楚触发次数,别自己吓自己。
6. 进阶玩法和工程化收尾
6.1 结合缓存记录,避免重复失败请求
如果你的图片经常在某些网络环境下失败,可以在本地做一层轻量记录:失败的 URL 短期内不再重试,直接走兜底。用Map存 URL 和失败时间戳即可:
const failCache = new Map() const FAIL_TTL = 60 * 1000 // 1 分钟 function shouldSkip(url) { const ts = failCache.get(url) if (!ts) return false if (Date.now() - ts > FAIL_TTL) { failCache.delete(url) return false } return true }这在弱网场景特别有用。用户在地铁里刷列表,一堆图失败,如果不做记录,每次滚动回来都重新请求一遍失败资源,浪费流量又拖慢体验。加个 1 分钟缓存,至少短时间内不会反复碰壁。这个思路和 HTTP 缓存的Cache-Control有点像,只不过我们缓存的是“失败状态”。
6.2 埋点上报:让图片失败不再是一笔糊涂账
线上图片失败率是个很有价值的指标。我在组件里会对外抛error事件,业务层统一收集上报。上报时至少带上这几个字段:图片 URL、当前页面路由、用户网络类型(navigator.connection支持的话)、失败次数。有了这些数据,你才能判断是 CDN 问题、特定用户网络问题还是图片资源本身缺失。
handleError() { // ...前面逻辑 this.$emit('error', { url: this.src, route: this.$route ? this.$route.path : '', retried: this.retried, online: navigator.onLine }) }上报要采样,不能每张失败图都全量上报,否则问题发生时你的监控接口先被打挂。我一般按 10% 采样,同时保证同一 URL 一分钟内只报一次。这个度需要根据你的流量规模调。
6.3 一个容易被忽略的兼容点
IntersectionObserver在很老的浏览器上不支持,如果你的项目要兼容老环境(比如某些内嵌 WebView),需要做兜底:不支持的场景直接把图片全部加载出来,退化成最早的行为。
mounted() { if (this.lazy && 'IntersectionObserver' in window) { this.initObserver() } else { this.startLoad() // 不支持就直接加载 } }这是一个很实用的降级思路:新能力用来优化,不能用来阻碍基本功能。懒加载没有,图片照样能显示,只是没那么省流量而已,功能不能因此挂掉。
写到这里,关于vue里图片加载与失败处理的主线基本讲完了。我自己在很多项目里用的都是“组件 + 原生IntersectionObserver+ 指数退避重试 + 失败缓存”这套组合,实测下来在图片密集的场景里既稳又可控。如果你只是个小项目,完全可以从最开始那三行@load + @error起步,需要什么再加什么,别一上来就上全套,过度设计反而是另一种坑。图片这种基础能力,代码量不大,但每个分支都对应着真实用户会遇到的场景,把它做扎实,用户和客服都会感谢你。