ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue图片加载与失败兜底:状态机、重试与封装实践

Vue图片加载与失败兜底:状态机、重试与封装实践 上周三下午运营同学在群里甩了一张截图活动页顶部的商品主图是一块惨白的空框旁边的标题文字和价格都正常显示只有那张图没了。我打开控制台看network 里那张图状态是 404——CDN 上的文件名被改过一次前端配置没跟着改。问题本身很简单但真正让我意外的是我们页面上明明写了图片加载失败的兜底逻辑为什么兜底也没生效顺着这条线查下去才发现 vue 里处理 img 标签的加载与加载失败远比load加error两个监听复杂得多。这篇就把我在图片加载这件事上踩过的坑、试过的方案、最终沉淀下来的一套写法完整讲一遍包括状态怎么建模、失败怎么兜底、指令和组件怎么封装、以及那些官方文档里一句话都没提但线上一定会遇到的边界情况。只要你在 vue 项目里跟图片打过交道不管是刚入门还是在维护一个跑了三年的老项目都能直接拿去用。1. 为什么 img 的 load 和 error 在 vue 里经常失灵1.1 从那次白图事故说起一个被跳过的 error 回调先复盘那次事故因为它的排查链路特别典型。当时的代码大概长这样template img :srcproduct.cover alt商品图 loadonLoad erroronError / /templateonError里做的事情是把product.cover换成一个本地的默认图。看起来没问题对吧但实际表现是图片 404 了onError没被调用页面就停在一片空白上。我用最土的办法定位——在onError第一行打了个console.log刷新没输出。然后我在 mounted 里手动查了一下const img document.querySelector(.product-cover) console.log(img.complete, img.naturalWidth, img.naturalHeight)结果是true 0 0。complete为 true 而naturalWidth为 0这在浏览器语义里等于加载流程已经结束但没有拿到有效图像。也就是说error 事件确实发生过只是发生在我们绑定监听器之前。这个结论有点反直觉。多数人默认我先渲染 img浏览器再去下载图片事件一定是我先绑好。但在下面几种情况下事件的触发时机会早于监听的绑定服务端渲染或预渲染时HTML 里已经带了src浏览器解析到标签就开始下载vue 水合hydrate在之后才跑图片命中强缓存从内存缓存或磁盘缓存直接解出整个过程可能在同一个宏任务里就完成了src是极小的 data URI 或 base64 内联图几乎没有网络过程用v-html或第三方富文本组件插入的图片vue 完全不参与其事件绑定。所以事件监听这个方案本身没错错在默认事件一定会晚于监听。正确的姿势是加一层同步校验兜底这一点后面第 5 节会展开讲。1.2 load 的编译结果它和原生 addEventListener 到底差在哪很多人以为 vue 的load是某种魔法其实在运行时它最终会落到addEventListener(load, handler)上。也就是说load和手写el.addEventListener(load, fn)在时效性上是等价的谁也不会比谁更早或更晚。真正的差异在这几个地方关注点模板里的 load手动 addEventListener绑定时机元素创建阶段vue 统一 patch完全由你控制可以放在任何生命周期解绑随组件卸载自动处理需要自己 removeEventListener能否同步校验 complete不能能绑定完立刻查一次动态换 src 时的重复绑定不会重复管理不当会重复绑定与原生属性冲突无需注意 onload 属性被覆盖关键结论是load适合做常规处理一旦涉及可能已经加载完的场景就必须在 mounted 里补一次complete检查。这不是 vue 的缺陷而是浏览器事件模型决定的任何框架都绕不过去。1.3 三种最容易被忽略的失灵场景除了上面说的时机问题还有三类场景会让人怀疑人生。第一类src传空字符串。这是最阴的一个。当:srcurl而url是、null或undefined时部分浏览器不会发起任何请求也不会触发 load 或 error。你的状态就永远卡在加载中骨架屏一直转圈。解决办法是在数据层就把空值挡掉。第二类服务器对不存在的图片返回 200。有些静态服务或网关会把 404 重写成 200 加一段 HTML 错误页这时浏览器拿到的是一个合法响应但解码失败的图片行为在不同内核下不一致有的触发 error有的直接渲染成空白。这种情况只能通过校验naturalWidth 0来判定不能只依赖 error。第三类v-if切换导致元素复用判断错误。vue 在更新时如果发现是同类型元素会复用它并只更新属性。你的图片组件从失败态切到新的成功地址时如果状态是写在外部的ref上很容易出现地址换了、状态还是failed的情况。这类问题在后面讲状态建模时会给出统一解法。2. 把图片加载抽象成一个四态状态机2.1 为什么不能用两个布尔值我见过很多项目的写法是用loading和error两个布尔值来控制展示代码长这样const loading ref(true) const error ref(false)问题立刻就来了loadingtrue, errortrue是什么状态没人知道。加上重试机制之后还会出现loadingtrue和errortrue同时为真的中间态模板里的v-if就会打架。更麻烦的是当src变化需要重置状态时你得记得同时把两个变量都改对漏一个就出 bug。正确的做法是把它们合并成一个枚举状态四个值互斥idle还没有有效的图片地址什么都不做不展示骨架屏loading正在请求或解码展示占位或骨架loaded拿到了有效图像展示真实图片failed重试次数用尽且确认无有效图像展示兜底图。这四个状态之间的流转是单向的、可预期的idle → loading → loaded或idle → loading → failed任何一步src变化都回到idle重新开始。写成表格看得更清楚当前状态触发条件下一状态界面表现idle拿到非空 srcloading显示骨架屏或主色调底loadingonload 且 naturalWidth 0loaded淡入真实图loadingonerror 或 naturalWidth 为 0重试未满loading继续骨架屏loading重试次数用尽failed显示兜底图或错误图标loaded / failedsrc 变化idle回到初始重新判断有了这张表模板就退化成一个纯粹的v-if分支逻辑集中在一处谁读都不会晕。2.2 用组合式函数封装 useImageState具体实现我用组合式函数来做因为一个页面里可能有列表、详情、头像多处用到抽出来复用最省事。核心思路是用new Image()先做一次预探测拿到结果后再决定真实img渲染什么。这样做的好处是真实图片永远不会出现先破图再换兜底图的闪烁。// composables/useImageState.js import { ref, watch, onBeforeUnmount, unref } from vue const MAX_RETRY 2 const RETRY_BASE_DELAY 300 function withCacheBust(url, n) { const sep url.includes(?) ? : ? return ${url}${sep}_retry${n}_t${Date.now()} } export function useImageState(src) { const status ref(idle) const naturalSize ref({ w: 0, h: 0 }) const finalUrl ref() const retryCount ref(0) let probe null let timer null const teardown () { if (probe) { probe.onload null probe.onerror null probe.src probe null } if (timer) { clearTimeout(timer) timer null } } const start (rawUrl) { teardown() retryCount.value 0 finalUrl.value if (!rawUrl) { status.value idle return } status.value loading const attempt (url) { probe new Image() probe.decoding async probe.onload () { // 关键只有拿到真实尺寸才算成功 if (probe.naturalWidth 0) { onFail(url) return } naturalSize.value { w: probe.naturalWidth, h: probe.naturalHeight } finalUrl.value url status.value loaded teardown() } probe.onerror () onFail(url) probe.src url } const onFail (url) { if (retryCount.value MAX_RETRY) { retryCount.value 1 timer setTimeout( () attempt(withCacheBust(url, retryCount.value)), RETRY_BASE_DELAY * retryCount.value ) } else { status.value failed teardown() } } attempt(rawUrl) } watch(() unref(src), (v) start(v), { immediate: true, flush: sync }) onBeforeUnmount(teardown) return { status, naturalSize, finalUrl, retryCount } }这段代码里有几个细节值得单独说。probe.src 是清理时的必要动作不然在旧浏览器上探测对象可能一直挂在内存里watch用了flush: sync是为了避免同一帧内 src 连续变化时出现状态错乱retryCount参与重试延迟计算形成300ms → 600ms的退避比固定间隔更友好。2.3 组件里怎么消费这个状态拿到状态之后模板就非常干净了template div classimg-box :styleboxStyle div v-ifstatus loading classskeleton / img v-else-ifstatus loaded :srcfinalUrl :altalt classreal-img loadonRealLoad erroronRealError / div v-else-ifstatus failed classfallback span图片暂不可用/span /div /div /template注意这里真实img上仍然保留了error。有人会问既然已经预探测过了为什么还要监听因为探测成功不代表真实元素一定成功——中间可能过了几秒CDN 节点切换了或者用户网络从 WiFi 切到弱网。探测是预测最终判定必须以真实元素为准。这种双保险在实际项目里救我过好几次。另外boxStyle建议用探测到的naturalSize算宽高比在loaded之前就用它撑起容器高度这样图片淡入时不会引起布局抖动——也就是常说的 CLS 问题。3. 加载失败的兜底占位、重试、降级三层防线3.1 第一层占位图与骨架屏怎么选兜底不是简单地失败了就换成默认图。我在实践中把它拆成三层每层解决不同的问题。第一层是视觉兜底解决空白很难看的问题。这里有个选择用统一默认图还是用骨架屏我的经验是按场景分列表页、瀑布流、头像这类尺寸小而数量多的图用骨架屏加主色调背景。因为默认图会有这张图我见过的重复感而骨架屏的呼吸动画能传递正在努力加载的感觉详情页主图、banner 这类尺寸大且位置固定的图用一张轻量默认图或品牌色块避免大面积闪烁明确失败已重试完的情况统一用一个带错误提示的兜底组件而不是继续转圈。用户看到一直转圈会以为卡死看到图片暂不可用反而会理解并继续操作。还有一个细节兜底图本身也可能加载失败。如果你的默认图走 CDN网络差的时候它同样会挂。所以我一般把兜底图做成内联 SVG 的 data URI它不产生网络请求永不失败export const FALLBACK_IMG data:image/svgxml;charsetutf-8, encodeURIComponent( svg xmlnshttp://www.w3.org/2000/svg width200 height200 rect width100% height100% fill#f2f3f5/ path dM60 130l25-32 20 24 15-18 25 26H60z fill#c9cdd4/ circle cx78 cy72 r10 fill#c9cdd4/ /svg )这段 SVG 我用了很多项目体积不到 500 字节比任何一张 PNG 都轻。3.2 第二层带退避的重试以及怎么防止死循环重试是最容易写出 bug 的一层。我在早期项目里干过一件蠢事在onerror里把src设回原地址结果就是error → 设置 src → error → 设置 src的无限循环浏览器 CPU 直接飙到 100%页面卡死。要避免死循环必须满足三个条件必须有次数上限。上面代码里的MAX_RETRY建议设为 2超过 2 次基本可以判定为资源真的挂了继续重试只是浪费流量必须换 URL。同一个 URL 直接重试浏览器很可能直接读缓存里的失败结果重试等于没重试。所以要加时间戳或随机参数打破缓存这就是withCacheBust的作用必须有退避。立刻重试在弱网下毫无意义因为网络还没恢复。我用的300ms * 次数是经验值实际项目里如果图片走的是自建服务可以按后端 QPS 承受能力调大。还有一点很重要重试的 URL 和后端统计口径要区分开。加了_retry参数之后CDN 的命中率和日志统计会把它们当成不同资源。如果你们有资源访问量报表记得在 CDN 侧配置忽略这些查询参数否则报表会难看。3.3 第三层多域名与多格式降级前两层是通用的第三层针对有一定规模的项目。先说多域名降级如果你有多个图片域名主域名加备用域名可以在重试时轮换域名。实现方式很简单把重试次数映射到域名索引const HOSTS [img1.example.com, img2.example.com, img3.example.com] function swapHost(url, index) { if (!HOSTS.length) return url try { const u new URL(url, location.origin) if (u.protocol.startsWith(http)) { u.host HOSTS[index % HOSTS.length] return u.toString() } } catch (e) { /* 相对路径或 data URI 直接返回 */ } return url }再说多格式降级。现代浏览器普遍支持 webp很多项目也会上 avif。但支持不等于所有场景都可用某些老版本内核对 avif 的支持有缺陷会出现能解码但显示异常的情况。稳妥的写法是用picture做声明式降级而不是在 JS 里做能力探测picture source :srcseturl .avif typeimage/avif / source :srcseturl .webp typeimage/webp / img :srcurl .jpg :altalt erroronError loadonLoad / /picturepicture的好处是浏览器自己会选择支持的格式且不支持时自动回退到img不需要你写任何兼容代码。唯一的坑是事件监听仍然要挂在img上挂在picture上是收不到的——我第一次用的时候就栽在这儿排查了半小时。4. 一次封装长期受益指令与组件两条路4.1 全局指令 v-img适合改造存量项目如果你手上是一个已经跑了很久的项目页面上到处散落着img一个个改成组件成本太高。这时候全局指令是最经济的方案它能做到业务代码几乎不动只把src换成v-img。// directives/img.js import { FALLBACK_IMG } from /assets/fallback const MAX_RETRY 2 const cache new Map() export const vImg { mounted(el, binding) { el.__imgState { src: null, retry: 0, timer: null } load(el, binding.value, binding.arg || FALLBACK_IMG) }, updated(el, binding) { if (binding.value binding.oldValue) return clearState(el) el.__imgState.retry 0 load(el, binding.value, binding.arg || FALLBACK_IMG) }, unmounted(el) { clearState(el) delete el.__imgState }, } function clearState(el) { const s el.__imgState if (!s) return if (s.timer) clearTimeout(s.timer) s.timer null el.onerror null el.onload null } function load(el, url, fallback) { const s el.__imgState if (!url) { el.src fallback return } if (cache.get(url) ok) { el.src url return } el.onload () { cache.set(url, ok) el.style.opacity 1 } el.onerror () { s.retry 1 if (s.retry MAX_RETRY) { s.timer setTimeout(() { const sep url.includes(?) ? : ? el.src ${url}${sep}_r${s.retry} }, 300 * s.retry) } else { el.onerror null el.src fallback } } el.src url }用的时候这样写img v-imgitem.pic :altitem.title / !-- 也可以指定兜底图 -- img v-img:https://cdn.example.com/default.pngitem.pic alt /这里有个小设计我想强调指令里加了一个cache来做成功结果记忆。同一个 URL 在页面里出现多次比如同一个运营位在多处展示第二次就不需要再走一遍完整流程直接赋值即可。对于长列表页这个优化非常明显实测能减少三成左右的重复网络判定开销。4.2 ProgressiveImage 组件适合新项目和对体验要求高的场景如果你是从零开始的项目或者对首屏体验有硬指标我建议直接上组件。组件能做的事情比指令多得多最典型的就是渐进式加载先展示一张极小体积的模糊缩略图LQIP等大图加载完再淡入替换。template div classprogressive :style{ paddingTop: ratio } img v-ifthumb status ! loaded classthumb :srcthumb alt aria-hiddentrue / div v-ifstatus loading classmask / img v-else-ifstatus loaded classfull :srcfinalUrl :altalt loadonRealLoad erroronRealError / div v-else classfallback / /div /template script setup import { computed } from vue import { useImageState } from /composables/useImageState const props defineProps({ src: { type: String, default: }, thumb: { type: String, default: }, alt: { type: String, default: }, ratio: { type: String, default: 66.66% }, }) const { status, finalUrl, naturalSize, retryCount } useImageState(() props.src) const onRealLoad () {} const onRealError () { if (retryCount.value 2) status.value failed } /scriptpaddingTop那行是用比例撑高容器防止图片加载完成时页面跳动。这个技巧比aspect-ratio兼容性更好老项目里也能用。缩略图建议在后端生成时就产出尺寸控制在 20×20 到 40×40 之间base64 内联进接口返回成本极低。4.3 懒加载指令和 IntersectionObserver 的正确结合图片加载绕不开懒加载。这里我要说一个很多人搞混的点原生loadinglazy和自定义 IntersectionObserver 不是二选一而是分工不同。原生属性的优点是零代码、浏览器原生优化缺点是阈值不可控、滚动容器嵌套时行为不一致、且和你的自定义状态机配合不好——因为在图片进入视口之前浏览器根本没开始加载你的loading状态会一直停着。而 IntersectionObserver 的优势是rootMargin可调能在图片距离视口还有 300px 的时候就提前开始加载。我的做法是懒加载用 IntersectionObserver 控制何时开始探测探测逻辑仍然复用状态机。指令简化版如下const io new IntersectionObserver( (entries) { entries.forEach((entry) { if (!entry.isIntersecting) return io.unobserve(entry.target) load(entry.target, entry.target.dataset.src, FALLBACK_IMG) }) }, { rootMargin: 300px 0px, threshold: 0.01 } )rootMargin给 300px 是个经验值。太小了用户快速滚动时会看到一片空白太大了又会一次性触发大量请求把带宽打满。如果你们的列表是横向滚动的或者嵌套在自定义滚动容器里记得把root设成那个容器否则监听不会生效——这是懒加载最常见的失效原因占了我在项目里遇到的同类问题的一大半。5. 文档不会提的四个坑内存、CORS、缓存与 SSR5.1 组件卸载后的回调与内存泄漏先看一个隐蔽的问题。上面所有方案里new Image()创建的对象都会持有onload回调而回调里又闭包引用了组件内的 ref。如果用户快速切换路由图片还在下载中组件就被卸载了这时回调触发操作的是一个已经卸载的组件状态——在 vue 3 里通常不会报错但内存不会释放积累多了就是明显的泄漏。所以每个探测对象在卸载时都必须显式解绑。我在useImageState的teardown里做的不只是清定时器还包括probe.onload null probe.onerror null probe.src probe null顺序不能反。如果先probe null再解绑你就拿不到那个对象了。另外probe.src 这一步在部分浏览器里会触发一次新的空加载所以放在解绑之后——这样即使触发了也不会再回调到组件。还有一个更长尾的坑如果你用了keep-alive缓存页面onBeforeUnmount不会触发。这时候要用onDeactivated配合处理或者在onActivated里重新检查一次状态。我遇到过用户反馈从详情页返回列表列表图片全变兜底图的问题根因就是 keep-alive 缓存了失败的中间状态。5.2 跨域图片的 onerror 判定与 crossorigin跨域图片有个容易被忽略的差异如果没设置crossorigin浏览器拿到的图片是可以正常显示的但你无法通过 canvas 读取它的像素数据会被污染。这个和onerror本身无关但如果你后续要做图片主色提取、生成缩略图等操作就会卡在这里。img :srcurl crossoriginanonymous erroronError /加上crossoriginanonymous之后请求会带上 Origin 头服务端必须返回Access-Control-Allow-Origin。如果服务端没配这个头加了 crossorigin 反而会导致图片加载失败。这是我见过最自找麻烦的坑本来图片好好的为了做取色加了 crossorigin结果全线白图。我的建议是只有在确实需要读取像素时才加这个属性并且上线前一定和服务端确认 CORS 配置。顺带说一个判定细节跨域图片加载失败时onerror能正常触发但你无法从事件对象里拿到具体的 HTTP 状态码只能知道失败了。所以排查时不要指望在 error 事件里看到 404 还是 403还是得开 Network 面板看。想要更精确的信息只能走一层自己的图片代理服务那是另一个话题了。5.3 缓存让重试看起来没生效第三个坑我第一次遇到时完全懵了。场景是图片 404重试两次第三次终于显示了兜底图。但如果我在重试时没有加时间戳会发生什么第二次和第一次的 URL 完全一样浏览器直接把上一次的失败结果从缓存里拿出来onerror立刻触发重试变成纯粹的瞬间空转。用户看到的现象就是兜底图直接就出来了根本没有重试过程。更麻烦的是那种服务器返回 200 但内容是错误页的情况。浏览器把这段 HTML 当成图片缓存下来后续所有重试都从缓存读永远失败永远触发不了你预期的重试逻辑。判定方式还是那个naturalWidth 0因为一张解码失败的图片complete是 true 但naturalWidth是 0。所以我的经验是任何重试都必须更换 URL哪怕只是加一个无意义的查询参数。这是硬规则不要心存侥幸。5.4 SSR 与预渲染下的水合不一致如果你的项目用了服务端渲染或静态预渲染图片这块会多出一类问题。服务端输出的 HTML 里src已经写死了浏览器开始解析就下载。vue 在客户端水合时如果status的初始值是loading而图片其实早就加载完了那么loading状态会一直显示到组件真正执行探测为止——中间可能出现骨架屏和大图同时存在的瞬间也就是所谓的闪烁。解法有两个方向。一是让服务端渲染时就输出正确的初始状态但服务端并不知道图片能不能加载成功所以只能输出中性状态比如idle或loading。二是水合后立即做一次同步判定import { onMounted, nextTick } from vue onMounted(async () { await nextTick() const el imgRef.value if (!el) return // 已经加载完且尺寸有效直接置为 loaded避免无谓的骨架屏 if (el.complete el.naturalWidth 0) { status.value loaded finalUrl.value el.currentSrc || el.src } else if (el.complete el.naturalWidth 0) { status.value failed } })这段同步检查就是开头那次白图事故的解药。它同时覆盖了缓存命中导致事件早于监听和服务端已渲染两种场景是我这套方案里最不能省略的一段代码。6. 别只盯着失败加载成功这条路上的性能取舍6.1 decoding 和 fetchpriority 该怎么配图片加载失败的处理是防守加载成功的性能是进攻。img上有几个原生属性值得配好。decodingasync表示让浏览器在合成线程之外解码图片不会阻塞主线程渲染。对于长列表里大量的小图这个属性的收益相当明显实测在低端安卓机上滚动帧率能提升不少。但注意如果这张图参与了首屏布局的关键计算同步解码反而更稳所以别全局乱加。fetchpriority是相对较新的属性用来告诉浏览器哪些图更优先。首屏 LCP 那张大图设成high其余全部low或auto。这个属性对首屏指标的改善有时候比任何 JS 优化都直接因为它影响的是浏览器发起请求的顺序你在 JS 层再怎么调度都改不了这个顺序。属性取值适用场景副作用decodingasync列表小图、非首屏图关键布局图可能出现短暂错位decodingsync首屏关键图阻塞渲染谨慎使用loadinglazy首屏之外的长列表与自定义懒加载需协调loadingeager首屏图无fetchpriorityhighLCP 主图、唯一同时设多个会互相摊薄优先级fetchprioritylow底部图片、装饰图无6.2 预加载与 IntersectionObserver 的参数调优预加载这件事要克制。我见过有人把整个列表的图片地址全用link relpreload塞进 head结果是首屏图片和所有预加载图片抢带宽首屏反而更慢了。正确的做法是只预加载下一页真正需要的那一张。具体到一个列表页我的策略是首屏可见的前 3 张图eager加fetchpriorityhigh串行发起首屏之外全部交给 IntersectionObserverrootMargin给 200 到 300px用户滚动后的下一步操作比如查看大图按钮对应的图片地址在按钮 hover 或页面空闲时用requestIdleCallback提前探测一次让用户点开时是秒开。rootMargin的调优没有一个万能值需要结合你们的图片平均体积和用户网络分布。我的做法是先给 300px 上线然后看埋点里的图片开始加载到可见这个时间差如果普遍超过 200ms 就把 margin 调大反之调小。6.3 一个容易被忽略的指标从点击到看到图最后说一个我在优化过程中才意识到的指标。我们一直盯着首屏和滚动流畅度但用户真正在意的是从我想看这张图到我看到这张图的延迟。这个延迟里网络只占一部分剩下的包括你从接口拿地址的时间、状态机切换的时间、图片淡入动画的时间。有一回我把淡入动画从 400ms 改成 150ms用户反馈感觉图片变快了但实际上网络耗时一点没变。这件事让我明白在处理图片加载这件事上感知性能和技术性能是两条不同的线做优化时两条线都得看。骨架屏、淡入时长、占位色这些看起来不技术的细节往往比多压 10KB 体积更能提升体验。我个人在实际操作中的体会是图片加载这套东西最好不要每个页面各写各的。抽成一层组合式函数加两条封装路径指令给存量组件给新建把重试、兜底、缓存、跨域这些边界情况全部收口在一处后面无论业务怎么变你要改的永远只有那几个文件。踩过几次坑之后我越来越确信图片加载看起来是 CSS 和标签的事本质上是一次异步状态管理只要用状态管理的思路去处理它绝大多数诡异现象都会变得有迹可循。
返回列表