线上页面上偶尔会冒出一张裂图:一个灰色小方块,或者浏览器自带的那枚破图标,旁边杵着一段 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" onerror="this.src='/static/default-avatar.png'" alt="用户头像">跑起来没问题,直到某天/static/default-avatar.png被打包工具改了路径,或者静态资源服务器的规则变了。这时候会发生什么?默认图也 404,onerror再次触发,又一次赋值给src,又一次失败。控制台里刷出一长串请求,页面卡住。这就是死循环陷阱,是onerror兜底最经典的翻车方式。
修复方式很简单,先解绑再替换:
<img src="/uploads/{{id}}.jpg" onerror="this.onerror=null;this.src='/static/default-avatar.png'" alt="用户头像">this.onerror=null的作用是把这个钩子摘掉,保证降级只走一次。我个人更推荐用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 的时序关系
loading="lazy"的图片在进入视口附近才开始加载,所以你在页面初始化时注册的 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 各测一次,确认两种情况都有合理表现。
这几步走完,基本能覆盖九成以上的裂图场景。我个人在这类问题上最大的体会是:兜底代码写得越少,说明前面挡得越好。与其把降级队列做成五六级,不如让后端保证字段可用、让存储侧有默认图、让前端在上传阶段就拦掉非法文件。真正的默认图,只需要在最后那一层安安静静待着,一年都不用被触发几次,这才是它最好的状态。