
线上页面上偶尔会冒出一张裂图一个灰色小方块或者浏览器自带的那枚破图标旁边杵着一段 alt 文字。用户不会跟你说这里 img 加载失败了他们只会说你们这网站看着不太靠谱。所以img 图片找不到时设置显示默认图片这件事乍看是一行onerror就能收工的活真落到有几十个页面、七八套模板的项目里能把人来回折腾半个月。这篇内容我打算把 img 的兜底方案从头到尾捋一遍浏览器到底在什么情况下会判定图片找不到、onerror这个看似简单的钩子为什么坑最多、多级降级队列怎么设计、全局兜底与行内写法各适合什么场合、上传预览场景里 FileReader 和 blob URL 的失效边界在哪以及怎么从后端和存储侧把裂图概率压下去。适合前端开发、有图片展示类业务的后端同学以及正在收拾历史页面遗留问题的维护者。代码都能直接抄但每段后面我会说清楚为什么这么写。1. 裂图是怎么发生的以及浏览器为什么从不主动提醒你1.1 从一次 404 到屏幕上那个破图标先把这个链条拆开。HTML 解析器遇到img会做三件事创建元素、把src交给资源加载器、给元素留出一块布局位置。这块位置很关键——img 是替换元素replaced element浏览器在资源没回来之前就已经按width/height属性或 CSS 尺寸给它占好了位置。资源加载失败后这块位置不会被删掉只会被填上一个破图的默认渲染或者一片空白加上 alt 文案。所以你看到的裂图本质上是盒子还在内容没了。这个特性导致两类很典型的观感差异写了固定宽高的图片裂图后是一个大小正常的灰块整齐但突兀没写宽高的图片裂图后盒子会塌到接近 0整个列表的排版直接错位后面所有元素往上跳。我见过最夸张的一次是商品列表一张主图挂了之后整个卡片的高度塌了一半鼠标移上去的 hover 区域也跟着错位用户点到了隔壁商品的加入购物车。浏览器不提醒你是有原因的。图片资源加载属于非阻塞渲染的旁路流程失败不会冒泡成 JS 异常不会出现在window.onerror的默认逻辑里也不会在控制台给你一条红色报错——顶多是一行 404 的网络记录混在几十条请求里。也就是说裂图是一种静默失败你不主动监听它就永远没人管。1.2 四种图片出不来处理方式完全不同很多人把图片裂了当成一个单一问题其实它的成因至少分四类前端能兜住的范围差别很大。下面这张表是我在实际排查中总结的分类建议收藏故障类型典型表现前端能否兜底推荐处理位置地址错误404、路径拼接少了前缀能前端兜底 后端修复数据权限受限403、防盗链拦截、签名过期能但只能显示占位后端重新签发或走代理网络层失败超时、DNS 失败、连接被重置能降级到本地资源内容解码失败200 但内容截断、格式不支持能降级到本地资源前三类里第一类是最该修的不该靠兜底掩盖第四类很多人没意识到服务器返回 200、Content-Length也对但图片字节流是坏的浏览器解码失败同样会触发error。这类问题在图片处理服务出故障时特别常见前端只能靠本地占位图顶住同时把原始地址上报出来。注意兜底方案是止损不是修复。默认图的职责是让页面别难看而不是让业务数据看起来正常。该报警的还得报警。2. onerror 直给的写法和它自带的三重陷阱2.1 最小可用版本长什么样最直觉的写法就是给 img 挂一个onerror失败了换成默认图。最小版本大概是这样img src/uploads/{{id}}.jpg onerrorthis.src/static/default-avatar.png alt用户头像跑起来没问题直到某天/static/default-avatar.png被打包工具改了路径或者静态资源服务器的规则变了。这时候会发生什么默认图也 404onerror再次触发又一次赋值给src又一次失败。控制台里刷出一长串请求页面卡住。这就是死循环陷阱是onerror兜底最经典的翻车方式。修复方式很简单先解绑再替换img src/uploads/{{id}}.jpg onerrorthis.onerrornull;this.src/static/default-avatar.png alt用户头像this.onerrornull的作用是把这个钩子摘掉保证降级只走一次。我个人更推荐用dataset打标记的方式语义更清楚也方便后续加日志img src/uploads/{{id}}.jpg >img src/uploads/a.jpg >function handleImgError(img) { const list JSON.parse(img.dataset.fallback || []); const idx Number(img.dataset.fallbackIndex || 0); if (idx list.length) { img.onerror null; img.style.visibility hidden; // 最后一级直接藏起来 return; } img.dataset.fallbackIndex idx 1; img.src list[idx]; } // 统一绑定避免行内写法受 CSP 限制 document.addEventListener(error, (e) { const el e.target; if (el.tagName IMG el.dataset.fallback) handleImgError(el); }, true);注意这里有个细节内联 SVG 的 data URI 里如果出现#必须编码成%23否则会被当成 URL 片段截断SVG 渲染成一片空白——这个坑我踩过一次排查了半小时才发现是颜色值没转义。3.3 顺手把裂图变成可统计的数据兜底之外更值钱的是可观测性。既然 onerror 已经触发了就顺手记一条图片地址、页面路径、naturalWidth/naturalHeight、是第几级降级生效的。攒起来批量上报别在回调里同步发请求否则一个页面挂十张图就是十次上报反而拖慢主线程。const failedImages []; function reportFallback(img, level, originalSrc) { failedImages.push({ src: originalSrc, level, path: location.pathname, t: Date.now() }); if (failedImages.length 10) flush(); }有了这些数据你就能回答几个很有价值的问题哪个业务线的图片挂得最多、是集中在某个 CDN 域名还是随机分布、默认图自己有没有失败过。这比事后再去翻日志高效得多。4. 全局兜底捕获阶段监听和 MutationObserver 的分工4.1 为什么要在 window 上用捕获阶段资源加载失败派发的error事件不会冒泡但它在捕获阶段是可以被外层接住的。这就是那行addEventListener(error, fn, true)里第三个参数为什么必须是true的原因——写 false 什么都接不到这是新手最常见的困惑。window.addEventListener(error, (e) { const el e.target; if (!el || el.tagName ! IMG) return; // 过滤掉 JS 报错 if (!el.dataset.fallback) return; // 没配降级的不处理 handleImgError(el); }, true);这里有个必须做的判断window上的error事件有两个来源一个是 JS 运行时异常e.target是window一个是资源加载失败e.target是具体元素。不区分的话你的图片处理函数会被 JS 报错反复触发。4.2 动态插入的图片怎么接住全局监听只对已经在 DOM 树里的元素生效。如果图片是 JS 动态创建、先绑 src 再插入在插入前就已经失败事件可能就错过了。稳妥做法是在插入时检查一次状态function mountImg(src, fallbacks) { const img new Image(); img.dataset.fallback JSON.stringify(fallbacks); img.src src; document.body.appendChild(img); // 缓存命中的失败图complete 为 true 且 naturalWidth 为 0 if (img.complete img.naturalWidth 0) handleImgError(img); }complete true naturalWidth 0是判断已经加载结束但没拿到有效像素的经典组合缓存和失败两种情况都能覆盖。如果页面里图片是被各种组件随手插进来的可以用 MutationObserver 扫一遍新增节点但对性能有成本我更倾向在组件层面统一封装一个SmartImg别在全局做这种兜底。全局方案适合接手历史项目、没法改动所有业务代码的情况。4.3 行内写法、全局监听、组件封装怎么选方案优点缺点适用场景行内 onerror零成本、一眼看懂受 CSP 限制、逻辑分散静态页、模板渲染全局捕获监听一处配置全站生效依赖 data 属性约定、动态插入要额外处理历史项目、存量页面组件封装类型安全、可测试需要改造调用方新项目、有构建流程CSP 那条尤其要注意如果站点配置了script-src且没有unsafe-inline行内的事件属性会被直接拦截浏览器控制台报一条违规图片就真的裸奔了。这种情况只能走全局监听或者外部脚本。5. 上传预览场景FileReader 和 blob URL 的失效边界5.1 预览图也会失败而且失败得更隐蔽上传头像、发帖配图这类功能本地预览通常用FileReader.readAsDataURL()转成 base64 塞进 img。这个过程同样会失败而且原因和网络加载完全不同文件根本不是图片用户把 pdf 改后缀传上来了、图片过大导致解码超时、HEIC 这类浏览器默认不支持的格式。function preview(file, imgEl) { if (!file.type.startsWith(image/)) { imgEl.src DEFAULT_AVATAR; return; } const reader new FileReader(); reader.onload (e) { imgEl.src e.target.result; }; reader.onerror () { imgEl.src DEFAULT_AVATAR; }; reader.readAsDataURL(file); }注意这里必须同时处理reader.onerror读取失败和 img 的onerror解码失败两者是独立的失败点。只处理一个用户就会看到一片空白。5.2 blob URL 性能更好但必须记得释放URL.createObjectURL(file)不走 base64直接给一个内存引用大图预览快很多。代价是这个引用会一直占着内存直到页面卸载或者你手动revokeObjectURL。用户连续切换十张图不释放内存曲线会明显往上爬。let currentUrl null; function previewByBlob(file, imgEl) { if (currentUrl) URL.revokeObjectURL(currentUrl); currentUrl URL.createObjectURL(file); imgEl.onerror () { imgEl.src DEFAULT_AVATAR; }; imgEl.src currentUrl; }经验值只要新图开始加载旧 URL 就该释放不用等新图加载完。因为 img 元素拿到 URL 后已经建立了引用提前释放不影响正在进行的加载。5.3 把注定失败的加载挡在前端与其让图片加载失败再去兜底不如在上传阶段就把不可能成功的拦下来。下面这几条我基本每个项目都会加上校验项阈值建议用在哪MIME 类型file.type以image/开头上传入口文件体积单图 5MB 以内上传入口真实格式读文件头 magic number服务端复核图片尺寸new Image()探测后判断长宽比裁剪前只做 MIME 判断是不够的file.type完全由浏览器根据后缀猜改个后缀就能绕过。真实格式得读前几个字节或者交给服务端做。前端加的这一层作用是减少无意义的网络往返不是安全边界。6. 从源头减少裂图后端、CDN 和懒加载该做的配合6.1 接口别返回空字符串让前端去猜很多裂图的根源在数据结构上。后端用户没有头像时返回前端src直接拼成当前页面地址或者拼出一个必然 404 的路径。更麻烦的是null和undefined混用模板里渲染出字面量undefined请求直接打到根路径上。比较省事的约定是涉及图片的字段后端永远返回一个可用的地址。没有头像就返回系统的默认头像地址让没有图这件事在服务端就解决掉。前端保留兜底逻辑但兜底不该是常态路径。这个约定一旦定下来前端模板里可以少写几十处三元判断。列表接口还要注意批量处理。一百条数据里混着三个空地址如果不做统一处理就会在列表里出现三个破图。可以在返回前统一map一遍把空值替换成默认地址比让每个前端页面各自处理可靠得多。6.2 对象存储侧的兜底手段如果图片放在对象存储上很多平台支持原图不存在时回源到指定默认图这类配置或者用图片处理参数统一输出规格。比如给缩略图地址统一拼上处理参数走 CDN 时由 CDN 决定是返回处理后的图还是默认图前端完全不用管。还有一种做法是让存储服务在图片不存在时返回 302 跳到默认图。这对前端最省事但要注意两点一是重定向后浏览器缓存的键变成了新地址原本的失败结果不会被记住每次访问都会再跳一次二是有些安全策略下跨域重定向会被拦反而变成新的失败原因。用之前最好小流量验证一遍。6.3 懒加载和 error 的时序关系loadinglazy的图片在进入视口附近才开始加载所以你在页面初始化时注册的 error 监听对一个还没加载的图片来说是空转的。等它真正进入视口、真正失败监听器还在事件能接住——只要你用的是全局捕获监听这点没问题。真正的坑在于自己实现的懒加载用 IntersectionObserver 进入视口才赋 src如果绑定的顺序写反了比如先赋 src 再绑 error在失败结果被缓存的情况下一些实现会同步派发失败回调或者触发时机早于绑定就接不住了。稳妥顺序永远是先绑 handler再赋 src。const io new IntersectionObserver((entries) { entries.forEach((entry) { if (!entry.isIntersecting) return; const img entry.target; img.addEventListener(error, onImgError, { once: true }); // 先绑 img.src img.dataset.src; // 后赋 io.unobserve(img); }); });7. 收尾前几个容易忽略的细节7.1 占位图用什么格式PNG、SVG、base64 各有取舍。彩色的小占位图用 PNG 就够了几十字节到一两 KB灰色块、纯色底这类几何图形用 SVG 更合适体积小、任意缩放不糊、还能用currentColor跟随主题色。base64 内联能省一次请求但体积会涨三分之一左右而且没法被单独缓存只在必须零请求的场景用。一个小优化如果默认图是纯色块别真的放一张图片文件直接用 CSS 给 img 元素设background-color配合object-fit让它在图加载失败时露出底色。这样连网络请求都省了是成本最低的一层兜底。7.2 alt 文本和隐藏策略图片加载失败时alt 文本会显示在破图旁边。如果 alt 写得很长页面上会出现一大段文字比破图还难看。所以 alt 要短、要准描述图片内容而不是写营销话术。纯装饰性的图片alt 写空字符串alt屏幕阅读器会跳过它裂图时也不会显示任何文字。反过来有信息价值的图片一定要写 alt——这是无障碍的基本要求也是图片完全加载不出来时用户唯一能获得的信息。如果连最后一级降级都失败了我一般的处理是把元素visibility: hidden而不改布局display: none会让整个网格重排visibility只是看不见占位还在页面不会跳。体验上更稳。7.3 上线前我自己会走一遍的检查清单断开网络刷新页面确认所有图片都降级到本地占位没有一直转圈或者空白塌陷。把默认图地址手动改成 404确认不会出现请求风暴控制台没有循环。打开控制台网络面板过滤图片请求看降级后的请求次数是否符合预期每张图最多额外一次。检查 CSP 配置确认行内的错误处理没有被拦截如果有拦截全局监听是否生效。在移动端上滑到页面底部再滑回来确认懒加载图片的兜底同样有效。上传一张改了后缀的非图片文件确认预览区显示的是默认图而不是空白。把接口里的图片字段改成空字符串和 null 各测一次确认两种情况都有合理表现。这几步走完基本能覆盖九成以上的裂图场景。我个人在这类问题上最大的体会是兜底代码写得越少说明前面挡得越好。与其把降级队列做成五六级不如让后端保证字段可用、让存储侧有默认图、让前端在上传阶段就拦掉非法文件。真正的默认图只需要在最后那一层安安静静待着一年都不用被触发几次这才是它最好的状态。