
图片加载失败这件事做前端的几乎都遇到过但真正把它当成一个独立问题去系统处理的人并不多。你打开一个电商列表页、后台管理表格或者社交动态流总能看到那么几张裂开的图标一片空白、一个灰色小方块、或者浏览器自带的那个破碎文件占位符。这就是img标签在找不到资源时的默认表现。而所谓设置显示默认图片说白了就是当浏览器拿不到真实图片时我们不让它自己去丢人而是主动塞一张我们自己准备好的图进去。这篇内容面向的是所有会写img标签的人——前端新手、接过老项目的维护者、做后台管理系统的全栈甚至只是想在个人主页上少一点裂图的手工站长。我会把原生 JS、Vue、React 三种写法都过一遍把死循环、重复请求、SSR 这些坑讲透也会顺带聊聊img在另一个语境里指代的固件镜像文件为什么和本文不是一回事。1. 为什么图片挂了值得当成一个正经需求来做1.1 一张裂图带来的三重损失很多人觉得图片挂了就挂了反正是用户网络问题。但从产品和工程角度看这件事的代价比想象中高得多。第一层是视觉层面的破窗效应一个页面上只要出现两三张裂图用户对整个平台的信任度会立刻下降尤其是电商和内容社区图片本身就是商品和内容的一部分图没了等于货架空了。第二层是布局层面的塌陷img在没有设置固定宽高时加载失败后会退化成很小的占位尺寸导致卡片高度跳变、列表参差不齐、瀑布流彻底乱套这种抖动在移动端尤其明显。第三层是数据层面的沉默图片挂了往往意味着后端存储、CDN 回源、对象存储权限或者用户上传流程出了问题如果没有兜底和统计这些错误会一直静默存在直到有用户投诉才被发现。我在接手过一个老后台系统时深有体会。那个系统的用户头像字段是直接存完整 URL 的早期迁移过一次云存储老数据的域名已经失效结果就是列表页里三分之一的头像全是裂图而且因为头像用的是border-radius: 50%裂图被裁成了一个个奇怪的半圆。当时没有兜底逻辑运维也不知道这件事直到某天领导截图问为什么这里有这么多破图才算暴露出来。从那以后我养成一个习惯只要页面上有img就必须给它一个明确的失败态。1.2 图片找不到的五种真实来源很多人第一反应是网络不好但实际排查下来图片拿不到的原因远不止这一种不同原因对应的处理策略也不一样。把这些来源列清楚你才知道默认图应该怎么配。失败来源典型表现是否可重试处理建议资源真的不存在404服务端返回 404 页面不建议直接换默认图同时上报域名或路径变更老数据指向已废弃域名不建议换默认图后台做数据清洗对象存储权限变化返回 403 或无权限 XML不建议换默认图检查桶策略网络中断、超时移动端弱网下大量失败可以重试有限次重试后再降级用户上传的文件本身损坏返回 200 但无法解码不建议换默认图这张表的意义在于默认图不是万能遮羞布它只是最后一道兜底。真正健康的做法是——能重试的先重试重试无效再降级到默认图同时把失败事件上报出去。如果所有失败都无脑换成一张灰图你等于亲手把线上问题藏了起来这是很多团队踩过的坑。另外有个小插曲值得说一句搜索img这个词的时候会混进来大量刷机固件相关的结果比如某些电视盒子、机顶盒的线刷镜像文件也叫.img。那是把整个磁盘镜像打包成单文件的格式和我们前端说的img标签是同名不同物。这篇内容只聊 HTML 里的图片固件镜像不在讨论范围内读者别被搜索结果带偏。2. 方案选型onerror、CSS 还是服务端兜底2.1 内联 onerror 的写法与它的三个坑最广为人知的写法是在标签上直接挂onerror这也是网上被复制最多的那段代码img srchttps://cdn.example.com/a.jpg onerrorthis.onerrornull;this.src/static/img/default-cover.png alt封面图这段代码里有两个细节是必须的。this.onerrornull是为了断开事件处理防止默认图本身也加载失败时反复触发onerror造成无限循环——这是最经典的浏览器死循环来源之一页面会直接卡死。第二个细节是this.src的路径必须写绝对路径或者基于根目录的路径因为相对路径是相对当前页面 URL 解析的在嵌套路由里很容易指错地方。它的问题也很明显。一是难以维护业务代码里散落几十处onerror哪天默认图要换尺寸或者加个埋点你得全局搜索替换。二是绕过了框架的状态管理React 里直接改 DOM 的src下一次渲染时 React 可能会把src改回原来的失败地址造成闪一下又裂开的诡异现象。三是无法做差异化不同业务模块的默认图不一样内联写法会把配置逻辑硬编码进模板。所以内联写法我的建议是——只适合原型、demo 和邮件模板这类极其简单的场景生产项目里最好别用。2.2 事件委托一个监听器管全站原生项目里更优雅的做法是全局事件委托。这里有个非常关键的冷知识error事件不会冒泡。所以你不能像处理click那样挂在document上等着它冒上来必须在捕获阶段拦截document.addEventListener(error, function (e) { const el e.target; if (!el || el.tagName ! IMG) return; if (el.dataset.fallbackApplied 1) return; el.dataset.fallbackApplied 1; el.src el.dataset.fallback || /static/img/default-cover.png; }, true); // 第三个参数 true 表示捕获阶段不能省注意末尾那个true这是整段代码的核心。资源加载类事件error和load一样都不参与冒泡流程只有在捕获阶段才能被外层容器感知到。很多人在document上加了监听却怎么都不触发八成就是漏了这个参数。用dataset.fallbackApplied打标记是为了防止默认图再次失败时无限循环这比直接置空onerror更好用因为它不受内联属性覆盖的影响。这种写法的好处是全站统一默认图路径可以从一个地方下发比如读document.documentElement.dataset.defaultImg也可以顺便在这里埋点上报统计每天有多少图挂了。缺点是它作用于全局如果某些图片你希望它失败时就保持原样比如用户明确知道自己在做灰度测试就需要额外的排除逻辑。2.3 CSS 兜底与 background-image 的取舍有人会问能不能纯用 CSS 兜底比如给img设置背景图图片加载失败时半透明地透出背景。可以做但有明显局限。img的背景图一直存在正常图片加载出来后如果它是带透明通道的 PNG背景图会透出来干扰显示。而且这种方式无法感知失败这个事件做不了埋点也无法在失败时替换alt文案。不过 CSS 有一个非常有用的辅助手段用aspect-ratio或者固定宽高把图片容器撑住无论图片是否加载成功布局都不会塌。这是兜底方案的重要组成部分——默认图解决的是显示什么尺寸约束解决的是占多大两者要一起做。我会在后面的章节里给出具体写法。真正适合彻底绕开img的场景是纯装饰性图片比如背景纹理、渐变遮罩、装饰边框。这些直接用 CSS 的background-image配合background-color作为兜底色比用img标签更省事。但只要有内容属性——用户头像、商品主图、文章配图——就应该用img并加alt这是无障碍和 SEO 的基本要求。2.4 服务端与 CDN 兜底的边界还有一派观点是默认图这事应该在后端或者 CDN 层解决。比如对象存储配置回源失败返回替代图或者网关层面拦截 404 返回一张占位图。这个思路有价值尤其是当你的图片来自第三方、你无法控制上游行为的时候。但它有三个边界必须清楚。第一服务端兜底返回的通常是带 HTTP 200 的图片前端根本感知不到失败你的监控就彻底瞎了。第二很多失败其实不是 HTTP 层面的失败而是资源存在但解码失败、格式不支持比如某些浏览器不支持 HEIC这时候服务端兜底完全无能为力。第三不同业务模块需要的默认图是不一样的服务端一刀切会很难看。所以我的实践结论是前端兜底为主服务端兜底为辅。前端负责感知失败、替换默认图、上报埋点服务端只处理那些前端完全拿不到响应的极端情况比如整个 CDN 域名都被解析失败。两者叠加覆盖率才够。3. 手把手实现从零写一个生产可用的默认图方案3.1 最小可用版本先从最简单的开始。核心逻辑只有三步监听错误、判断是否已经处理过、替换src。为了避免默认图本身也挂掉我强烈建议把最后兜底的默认图做成本地资源甚至是内联的 base64 小图。img src/uploads/2024/a.jpg >const DEFAULT_IMG /static/img/cover-default.png; function applyFallback(img) { if (img.dataset.state fallback) return; // 已经降级过 const fallback img.dataset.fallback || DEFAULT_IMG; img.dataset.state fallback; img.removeAttribute(srcset); // 清掉 srcset 防止再次触发 img.src fallback; img.classList.add(img-fallback); } function retryOnce(img) { const origin img.dataset.origin || img.src; const tried Number(img.dataset.retry || 0); if (tried 1) return applyFallback(img); // 只重试一次 img.dataset.retry String(tried 1); const url new URL(origin, location.href); url.searchParams.set(_r, Date.now()); // 打时间戳绕过失败缓存 img.src url.toString(); }这里有两个经验点。一是removeAttribute(srcset)如果原图带了响应式srcset只改src可能不会生效浏览器还是会去挑srcset里的候选地址。二是重试时加时间戳参数因为失败响应很可能被浏览器或中间层缓存了直接重试同一个 URL 会立刻再失败一次。时间戳参数是关键但注意它会让 CDN 缓存命中率下降所以只在重试那一次加正常加载不要加。至于dataset.state这个标记它的作用是让降级成为一个单向不可逆的状态。一旦降级到默认图就再也不重试、不再监听避免任何形式的循环。这是血泪教训我曾经写过一个逻辑失败后换默认图默认图有一次因为构建产物没上传导致 404结果页面陷入了换图→失败→再换图的死循环浏览器标签页直接无响应。3.3 Vue 3 指令版实现在 Vue 项目里最合适的载体是自定义指令因为它天然作用于元素本身又能在多处复用。下面这个版本我在几个项目里都跑过稳定性没问题// directives/fallbackImage.js const DEFAULT_IMG /static/img/cover-default.png; export const fallbackImage { mounted(el, binding) { const defaultSrc binding.value || DEFAULT_IMG; const handler () { if (el.dataset.fallbackApplied 1) return; el.dataset.fallbackApplied 1; el.removeAttribute(srcset); el.src defaultSrc; el.classList.add(img-fallback); }; el.__fallbackHandler__ handler; el.addEventListener(error, handler); // 处理已经在缓存里坏掉的图片部分浏览器不会再次触发 error if (el.complete el.naturalWidth 0) handler(); }, updated(el, binding) { if (el.getAttribute(src) ! el.dataset.lastSrc) { el.dataset.lastSrc el.getAttribute(src) || ; delete el.dataset.fallbackApplied; } el.__defaultSrc__ binding.value || DEFAULT_IMG; }, unmounted(el) { if (el.__fallbackHandler__) { el.removeEventListener(error, el.__fallbackHandler__); delete el.__fallbackHandler__; } } };模板里这样用img v-fallback-image/static/img/avatar-default.png :srcuser.avatar alt用户头像这段代码里有个容易被忽略的判断el.complete el.naturalWidth 0。某些浏览器在处理之前已经加载失败并被缓存的图片时不会重新触发error事件但complete会是true而naturalWidth是 0。这个组合条件是识别缓存坏图的经典技巧不加的话你会发现在刷新页面时有些图依然是裂的但换个浏览器又好了非常难排查。unmounted里清理监听器也是必须的尤其是配合v-for的列表和虚拟滚动不然长时间运行的单页应用会积累大量无用监听。3.4 React 组件与 Hook 版React 里不能像 Vue 指令那样直接操作 DOM因为下一次渲染可能把你改的src覆盖回去。正确姿势是把当前该显示哪个地址变成组件状态import { useEffect, useRef, useState } from react; const DEFAULT_IMG /static/img/cover-default.png; export default function SmartImage({ src, fallback DEFAULT_IMG, alt , ...rest }) { const [current, setCurrent] useState(src); const failedRef useRef(false); useEffect(() { failedRef.current false; setCurrent(src); }, [src]); const handleError () { if (failedRef.current) return; // 防止默认图再失败时无限 setState failedRef.current true; setCurrent(fallback); }; return ( img {...rest} src{current} alt{alt} onError{handleError} style{{ background: #f2f3f5, objectFit: cover, ...(rest.style || {}) }} / ); }failedRef这个 ref 是整个组件的安全阀。如果只依赖setCurrent(fallback)当fallback本身也加载失败时会再次触发onErrorsetCurrent的值没变、React 会跳过重渲染看起来没事但如果 fallback 是从 props 动态传入的、每次都是新字符串就会进入无限渲染。用 ref 做一次性标记是最稳的。还有一个细节useEffect里依赖src重置状态。这在列表虚拟滚动、分页切换时非常重要。如果不重置一张图降级成默认图之后即使新数据给了正确的地址组件也永远显示默认图了这是实战里非常隐蔽的 bug。3.5 接入懒加载与响应式图片现代项目里img往往还带着loadinglazy、srcset、sizes这些属性。加了默认图逻辑之后这几个属性会互相干扰需要专门处理。loadinglazy的图在进入视口前不会发起请求自然也不会触发error这是正常行为不用处理。但要注意懒加载元素上的error监听依然有效因为请求发出后失败还是会派发事件。srcset则麻烦一些。当浏览器根据srcset挑选候选地址时失败事件触发后如果你只改src浏览器有可能继续按srcset去选导致改动被忽略。所以我在降级函数里都会先removeAttribute(srcset)再改src同时把sizes一起清掉。这个操作不可逆但降级本身就是不可逆的逻辑上是自洽的。响应式还有一个容易忽视的点默认图也应该是响应式的。如果你的默认图只有一张 2000px 的大图移动端加载它会白白浪费带宽。我通常的做法是准备两档默认图——一张 200×200 的方图用于头像和缩略图一张 800×450 的横图用于内容主图通过>function previewImage(file, imgEl, fallback) { const isImage file /^image\//.test(file.type); if (!isImage) { imgEl.src fallback; return; } const reader new FileReader(); reader.onload (e) { imgEl.src e.target.result; }; reader.onerror () { imgEl.src fallback; }; reader.onabort () { imgEl.src fallback; }; reader.readAsDataURL(file); }有三个点值得强调。第一reader.onerror和onabort都要挂因为用户可能在中途取消操作abort不会走error分支。第二对于大文件readAsDataURL会把整个文件读进内存并做 base64 编码体积会膨胀约 33%几十兆的图很容易把页面卡住这种场景更适合用URL.createObjectURL(file)用完记得URL.revokeObjectURL()释放否则会造成内存泄漏。第三预览失败时给的默认图最好和未上传状态的占位图一致让用户明确知道这张没选上而不是以为上传成功了。4.3 不同业务场景要区别对待默认图不是一张图走天下不同模块的诉求差别很大我一般会按下面的策略分开配。场景推荐默认图尺寸策略附加要求用户头像灰色人形图标或首字母色块固定正方形圆形裁切可基于用户名生成稳定色商品主图品牌灰底 Logo1:1 或 4:3 固定需要埋点统计失败率文章封面分类相关的插画16:9 固定可按分类切换不同默认图后台表格缩略图极简灰色方块小尺寸弱化视觉不抢眼重点是数据富文本内嵌图提示文案 图标自适应宽度需要保留原始链接供排查这里有个我觉得很好用的小技巧用文字首字母生成默认头像。用户名字的第一个字或者第一个字母配上根据名字哈希出来的一个稳定背景色生成一个色块头像。这样比统一的灰人图标信息量高而且每个用户看起来都不一样页面不会显得死气沉沉。实现上只需要一个canvas或者div加 CSS 就行不需要后端参与。至于富文本里内嵌的图片我倾向于不用默认图替换而是显示一个带提示的占位块因为富文本的图片往往是有具体语义的替换成通用默认图反而会让人误读内容。占位块上可以保留原始地址方便编辑排查。5. 常见问题与排查技巧实录5.1 死循环、重复请求与内存泄漏这三个问题是默认图方案里最常翻车的点我把它们放在一起讲因为成因高度相关。死循环的根源永远只有一个降级之后又被降级。表现形式是浏览器风扇狂转、标签页无响应。判断方法很简单打开 Network 面板看是否有同一个地址在被反复请求。解决办法就是前面说的状态标记法用一个dataset属性或者ref把元素锁死在已降级状态任何后续错误都直接 return。重复请求的表现是同一个失败地址被请求了两次以上。原因是重试逻辑和降级逻辑同时生效了。我的处理原则是要么重试要么降级不要既重试又降级。比如设定重试上限为 1 次第一次失败后重试第二次失败后直接降级中间不要再插入新的重试分支。内存泄漏一般出现在单页应用里。监听器挂上去了没清理组件销毁后error事件还是会往一个已经脱离文档的节点上派发。表现是页面用得越久越卡。解决办法是在框架的卸载钩子里显式removeEventListenerVue 用unmountedReact 用useEffect的清理函数。这个习惯一定要养成。5.2 跨域、SSR 与缓存带来的边界问题跨域方面默认图方案本身是安全的因为我们只是改src属性没有读取图片像素数据。但如果你同时在做canvas截图、图片裁剪这类需要读取像素的功能就要注意跨域图片会污染画布必须给图片来源加 CORS 响应头并在img上设置crossOriginanonymous。加了 CORS 之后失败的表现可能从 404 变成 CORS 错误但都会触发error事件兜底逻辑照样有效。SSR 方面服务端渲染时页面结构是先输出 HTML 的此时还没有 JavaScript任何纯 JS 的兜底逻辑都要等水合完成才生效。这中间有一个窗口期如果图片在这段时间内失败了用户会先看到裂图。解决办法是在 HTML 层面就写好内联的onerror让它在水合之前就能执行等框架接管后再由组件接管状态。这是 SSR 项目里比较隐蔽的一个点。缓存方面有两种情况要分开看。一是失败响应被缓存导致后续重试依然失败这个用时间戳参数解决。二是图片被浏览器当作正常资源缓存了但内容是错的比如 CDN 缓存了一个错误页面这种情况需要清理 CDN 缓存前端无能为力只能靠监控发现。5.3 问题速查表实战中遇到的现象和对应排查方向我整理成了下面这张表遇到问题可以直接对号入座。现象可能原因排查动作默认图完全没生效error监听加在了冒泡阶段检查addEventListener第三个参数是否为true页面卡死、风扇狂转降级后再次触发降级检查是否有状态锁Network 面板看重复请求刷新后部分图仍裂开坏图被缓存未触发error增加complete naturalWidth 0判断默认图显示但尺寸怪未设宽高或被srcset干扰补width/height降级时清srcset切换数据后一直显示默认图组件状态未随src重置检查依赖数组或watch是否监听src后台图片大面积失败存储域名或桶权限变更看 Network 状态码是 403 还是 404移动端失败率明显偏高弱网导致的临时失败加入有限次重试并统计网络类型这张表里我最想强调的还是第二条。死循环这个问题一旦发生就是页面级别的灾难而且因为只出现在特定条件下测试阶段很容易漏掉。我的建议是在开发阶段主动做一次默认图也 404的测试把默认图路径临时改成一个不存在的地址看页面会不会卡死。这个五秒钟的自测能帮你避开一个上线后可能被投诉的严重事故。6. 工程化收尾把默认图当成基础设施来管6.1 默认图的资源规范与体积控制默认图虽然小但它出现在每一个失败的位置很可能是页面上重复次数最多的图片。如果它体积大就等于给每个失败位置都加了一份带宽负担。我的规范是默认图统一用 SVG 或者体积小于 3KB 的压缩格式尽量内联成 base64 或者雪碧图让它跟着主 JS/CSS 一起下发避免额外请求。这里有个取舍内联 base64 会增大主包体积但如果默认图需求量很大省下的请求数远比那几 KB 值钱。我的经验阈值是 4KB 以内内联超过就走 CDN。另外要保证默认图的色调和产品整体视觉一致别用一张刺眼的红图当兜底那会让页面看起来像报错现场。6.2 失败率埋点让沉默的问题浮出水面最后想聊的是监控。默认图最大的副作用是掩盖问题如果不上报你永远不知道有多少图片在悄悄降级。我通常会在降级函数里加一个轻量的上报注意要做节流和采样避免失败量大时把埋点接口打爆。const reportQueue []; let timer null; function reportFallback(url, fallback, reason) { reportQueue.push({ url, fallback, reason, t: Date.now() }); if (timer) return; timer setTimeout(() { const payload reportQueue.splice(0, 20); // 每次最多上报 20 条 navigator.sendBeacon navigator.sendBeacon( /api/monitor/img-fallback, JSON.stringify(payload) ); timer null; }, 3000); }用sendBeacon而不是普通的fetch是因为它不阻塞页面卸载用户快速关页面时数据也能发出去。批次上报而不是一条一个请求是为了避免请求风暴。这两个细节都是踩过坑之后才加上的一开始我用fetch逐条上报结果在一个图片大面积失败的时刻埋点请求把主接口的并发额度占满了页面反而更慢。有了这些数据你就能在后台看到每天有多少张图降级、集中在哪些域名、哪些业务模块。如果某天某个域名下的失败率突然从 0.1% 涨到 30%那基本上就是存储或者 CDN 出问题了比等用户投诉快得多。我个人在实际使用中最大的体会是默认图这件事代码层面写对只是及格线真正拉开差距的是你有没有把它和监控、数据清洗、资源规范串成一条链路。孤零零的一段onerror代码救不了线上但一条完整的兜底链路能。