前端监控这几年做下来,我一直保留着一个习惯:新项目上线前,第一件接入的东西不是埋点 SDK,也不是错误上报框架,而是先把资源加载失败的全局捕获写好。原因很简单——图片裂了、脚本挂了这类问题,用户不会主动帮你报,浏览器也不会默认把错误送到你面前,但你完全可以通过一段代码把这些状态全部兜住。这篇文章就把我用 error 事件做全局捕获的完整方案、原理和踩坑记录一次说清楚,适合正在搭前端监控、或者被线上资源加载问题困扰的朋友直接照着落地方案改。
1. 为什么要在全局层面对付资源加载失败
1.1 资源加载失败带来的连锁反应
很多人觉得资源加载失败就是“显示个裂图”而已,其实远不止。我遇到过几个真实案例。
第一个案例是电商活动页,轮播图某天突然全裂了,原因是运营在后台更换图片时填错了相对路径,导致页面几十张 banner 全部请求 404。用户看到的不是一张裂图,而是整个首屏视觉垮掉,当天转化率掉了将近两成。如果没有全局捕获,这个问题可能在线上挂好几天才被发现。
第二个案例更有意思。一个后台管理系统接入了第三方地图 SDK,正常情况下页面加载后会自动初始化地图。某天第三方 CDN 的一个脚本文件更新出了问题,HTTP 返回了 500,但页面其他部分照常渲染。结果用户进入页面后发现地图区域一片空白,控制台里只有一句不起眼的报错,非技术人员根本发现不了。这种“静默失败”比显式报错更可怕,因为整个功能模块直接不可用了,你却不知道是哪一行代码没有执行。
第三个案例是动态加载路由的场景。用 webpack 做代码分割的时候,某个 chunk 文件因为发布过程中覆盖顺序错误,导致用户访问某个路由时import()请求返回 404,整个页面白屏。这种动态脚本加载失败如果不做全局兜底,前端监控只能记到一句Loading chunk xxx failed,想定位到具体是哪个 chunk、哪个用户、哪个页面触发,还需要额外信息。
这些场景的共性是:资源加载失败不像 JS 运行时报错那样打断执行、抛出一个明显的异常,它更多是静默的、分布式的、容易被忽略的。所以我们必须有一个全局层面的监听器,把所有资源加载失败的事件统一捞起来,再结合页面上下文判断影响范围。
1.2 为什么逐个绑定不可行
你可能会想,图片加载失败不是有img.onerror吗?脚本加载失败不是有script.onerror吗?而且很多图片标签上可以直接写onerror="this.src='fallback.png'",这不是挺简单的吗?
逐个绑定的问题主要有三个。
第一,覆盖不全。页面上可能有几十张图片、十几个脚本,靠手动一个个绑onerror,漏掉一个就意味着那条链路的失败状态对你完全不可见。而且随着页面迭代,新加的图片标签很容易忘记挂处理逻辑,每个开发者的习惯又不一致,长期维护成本极高。
第二,逻辑分散。就算你每个元素都绑了onerror,错误信息是分散在各个元素上的。今天想统计“页面上一共有多少张图挂了”,你得把所有回调汇总,数据口径很难统一。更别说要根据错误类型做去重、限频、上报、降级这些统一动作。
第三,动态元素绑不住。现代页面里大量节点是异步渲染出来的,图片是接口返回后再创建的,脚本是按需注入的。如果只在初始 DOM 上绑定,后插入的节点你根本管不到,除非每次创建元素时都手动挂回调,那代码可读性就彻底毁了。
所以,对资源加载失败这件事,正确的姿势一定是全局捕获:一段代码注册到 window 上,页面里所有符合规则的图片、脚本、样式表加载失败时,都会自动汇聚过来。
2. 动手前先把 error 事件的运行机制摸透
2.1 资源加载错误不冒泡,只能在捕获阶段拦截
这里有一个特别容易踩的坑,我见过好几个同事都栽在这里。很多人都知道window.addEventListener('error', handler)这段写法,但不知道第三个参数true有多关键。
浏览器事件流分为三个阶段:捕获阶段、目标阶段、冒泡阶段。我们平时熟悉的click事件,从 window 一路向下传达到目标元素,再一路向上冒泡回 window,所以你在 document 上监听 click,也能收到子元素的点击。这种习惯让很多人默认以为error事件也是这样。
但实际上,图片、脚本、样式表这类资源加载错误触发的error事件,是不冒泡的。它只会在捕获阶段沿着window → document → ... → 目标元素这条路径传递,到达目标元素后事件就结束了,不会原路返回。这意味着如果你写成:
window.addEventListener('error', function (event) { console.log('捕获到资源错误', event.target); }); // 没有传 true,监听的是冒泡阶段那么图片加载失败、脚本加载失败的事件,根本不会到达你这个监听器。这个问题极其隐蔽,因为如果你的代码还同时监听了运行时报错,报错日志依然能正常输出,你会以为自己写的全局捕获是生效的,实际上资源加载失败一直在悄悄漏掉。
正确的写法必须开启捕获阶段:
window.addEventListener('error', function (event) { console.log('捕获到资源错误', event.target); }, true);2.2 window.onerror 和 addEventListener 各管一摊
这里还有一个常见的概念混淆:window.onerror和window.addEventListener('error', handler, true)到底有什么区别?
先说window.onerror。它负责的是JavaScript 运行时报错,也就是代码执行过程中抛出的未捕获异常,比如TypeError: Cannot read property of undefined、语法解析错误、异步回调里抛出的异常等。它的回调参数是message, source, lineno, colno, error这一串,能拿到出错的文件、行号、列号和 Error 对象。注意,普通资源加载失败(img 的 src 404、script 404)不会触发window.onerror。
而window.addEventListener('error', handler, true),如果你开启了捕获阶段,它能同时收到两类东西:
event.target是某个元素(比如img、script、link)时,说明是资源加载失败;event.target是window本身时,说明是未捕获的 JS 运行时报错。
所以严谨一点说,现代前端监控里的全局 error 捕获,应当以addEventListener('error', handler, true)为主,window.onerror用得越来越少,因为前者覆盖更全面。但要注意,两者如果同时挂上,同一个运行时报错会被触发两次,上报时一定要做去重,否则排查问题时数据会显得很乱。
2.3 为什么跨域脚本可能只告诉你一句 Script error
再讲一个让很多人抓狂的机制。当你页面上加载了一个跨域脚本,比如第三方统计 SDK,而这个脚本内部执行出错了,你的window.onerror或者捕获阶段的监听器里,能拿到的错误信息可能只有一句Script error,没有文件名、没有行号、连基本的错误描述都没有。
这是浏览器出于安全考虑做的一个“净化”操作:跨域脚本的详细堆栈信息默认不暴露给调用方页面,除非同时满足两个条件。一个是在加载脚本的标签上声明crossorigin="anonymous",另一个是脚本所在服务器响应头里返回了正确的Access-Control-Allow-Origin。
如果你希望监控到第三方脚本内部的报错细节,这块要尽早和资源方沟通,把 CORS 头加上。不过好消息是,即使拿不到错误详情,event.target.src还是拿得到的,你可以至少知道“哪个域名的哪个脚本在用户浏览器里报错了”,这对定位问题已经很有帮助了。
2.4 关于事件循环和错误时机的补充
还有一点,做全局捕获时最好对 JS 的事件循环机制有基本认识。资源加载错误是异步触发的,它不一定发生在页面初始加载阶段,可能是用户滚动到某个区域才触发懒加载图片失败,也可能是某个交互按钮点击后才动态注入脚本失败。正因为如此,监听器必须在很早的时候就挂上,最好是一段独立于业务代码的、在入口文件顶部就执行的逻辑。
我见过有的项目把这段监听写在某个组件的mounted里,结果组件还没渲染完成时发生的资源错误全部错过。解决方案很简单:把全局捕获逻辑抽成一个独立模块,在应用入口的最前面import并且立即执行,不要等任何 DOM 就绪或框架初始化完成。
3. 一套可以直接抄走的全局捕获实现
3.1 最基础的核心代码
先给出一段可以直接放进项目里的核心实现,再逐行解释关键点。
(function () { // 最近一次错误记录,用于简单去重 const errorCountMap = new Map(); function reportResourceError(event) { const target = event.target; // 只关心资源加载错误,忽略 window 上的运行时报错 if (!target || target === window) { return; } const tagName = target.tagName ? target.tagName.toLowerCase() : 'unknown'; const src = target.src || target.href || target.currentSrc || ''; const timestamp = Date.now(); // 同一资源在 5 秒内重复报错,只记一次 const dedupeKey = tagName + '|' + src; if (errorCountMap.has(dedupeKey)) { const last = errorCountMap.get(dedupeKey); if (timestamp - last < 5000) { return; } } errorCountMap.set(dedupeKey, timestamp); const info = { type: 'resource_error', tagName: tagName, src: src, pageUrl: location.href, userAgent: navigator.userAgent, timestamp: timestamp }; // 控制台输出方便本地调试 console.warn('[全局资源错误捕获]', info); // 这里可以替换成你的上报通道 sendReport(info); } function sendReport(info) { if (navigator.sendBeacon) { const blob = new Blob([JSON.stringify(info)], { type: 'application/json' }); navigator.sendBeacon('/api/log/error', blob); } else { const img = new Image(); img.src = '/api/log/error?data=' + encodeURIComponent(JSON.stringify(info)); } } window.addEventListener('error', reportResourceError, true); })();这段代码有几个值得说明的点。
第一,开头的去重逻辑。实际线上环境中,一个页面可能因为网络抖动,导致同一张图片在短时间内反复加载失败并反复上报,如果不加限频,上报接口会被刷爆,监控平台也会被无效数据淹没。我这里的策略是同一资源 5 秒内只报一次,你可以根据业务调整窗口时间。
第二,src的获取。图片资源取target.src,脚本资源通常也有src,样式表取target.href。如果是使用srcset的响应式图片,有时候target.currentSrc才是真正发起请求的地址,所以我在代码里做了多字段兜底。
第三,上报通道选择。navigator.sendBeacon是首选,因为它在页面卸载时也能尽力把数据发出去,很适合错误日志这种“用户马上就要离开页面”时的数据。不支持时退回new Image()打点,这是最老牌却也最稳定的上报方式,没有跨域问题也不需要额外依赖。
3.2 细节增强:把监控做成生产级
上面的代码是一个能跑的骨架,但真到了生产环境,我觉得至少还要加这几层处理。
区分资源类型再做不同动作。图片失败和脚本失败的正确处理方式是不同的。图片失败可以考虑自动降级成占位图,脚本失败则要触发功能模块的重试逻辑。所以监听器里要对 tagName 做分支处理:
if (tagName === 'img') { // 触发占位图替换或重新加载策略 replaceWithPlaceholder(target); } else if (tagName === 'script') { // 记录脚本 src,后续做脚本重新注入 queueScriptRetry(target.src); }图片降级这里提醒一句:不要直接在img上写onerror="this.src='fallback.png'"这种内联代码,因为一旦 fallback 图片本身也加载失败,会陷入死循环。虽然现代浏览器里可以通过target.onerror = null提前把回调清掉,但更稳妥的做法是在全局捕获的逻辑里去判断:如果该图片已经指向了占位图,就不要再替换了,避免循环替换。
带上源代码位置和版本号。上报数据里除了资源地址,最好把当前页面的location.href、navigator.userAgent、甚至是你部署时的版本号带上。这样当收到一个脚本加载失败的报告时,你才能知道这是哪个页面版本、哪个浏览器下的问题。我有一个习惯是把应用版本挂到全局一个变量上,比如window.__APP_VERSION__,上报时直接取,排查发布相关的问题特别有用。
开发环境下做噪音隔离。开发环境下,如果要调试,建议把捕获到的错误打印出来。但生产环境默认不要打印,否则用户控制台一行接一行的警告影响页面性能,而且部分用户会因此觉得你的站点有问题。可以用环境变量或者location.hostname判断一下当前是不是本地环境。
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { console.warn('[全局资源错误捕获]', info); }3.3 与 Vue、React 等框架的错误处理配合
很多人会问,我项目里已经用了 Vue 的app.config.errorHandler或者 React 的 Error Boundary,还需要这套全局捕获吗?
答案是需要,但它们是不同维度的东西。框架的错误处理器捕获的是组件生命周期或渲染过程中抛出的异常,它们做的事情是“组件出错了不要白屏,给个降级 UI 并记录错误”。而error事件捕获的是浏览器资源加载层面的失败,是发生在框架之外的。两者是互补关系。
一个典型的完整监控组合是:
// Vue 3 示例 import { createApp } from 'vue'; const app = createApp(App); app.config.errorHandler = (err, instance, info) => { // 框架层面的错误,这里拿到的是 Vue 组件栈 sendReport({ type: 'vue_error', message: err.message, component: instance?.$options?.name || 'unknown', info: info }); }; // 全局资源加载失败检查仍然保留 window.addEventListener('error', handleResourceError, true);React 那边则是用 Error Boundary 包裹根组件捕获渲染错误。但你要记住,无论是 Vue 的errorHandler还是 React 的 Error Boundary,全都管不到“页面上一张图片 404”或者“一个外部脚本没加载出来”这件事,所以全局error监听不能省。
这里再提一个和框架组合时常见的诉求:iframe 里的资源错误怎么办。如果你页面里嵌了 iframe,且 iframe 是同域的,你可以在主页面里给iframe.contentWindow也挂一个监听:
const iframe = document.getElementById('myIframe'); iframe.contentWindow.addEventListener('error', function (event) { console.log('iframe 内部资源错误', event.target); }, true);如果 iframe 是跨域的,那么出于浏览器的同源策略,主页面无法直接访问 iframe 内部的事件,你只能靠 iframe 内部自己的代码做上报,或者接受这块监控盲区。这一点在排查“页面里明明有报错但全局监控没有记录”的时候最容易让人困惑,先判断是不是跨域 iframe 的问题。
4. 实操中踩过的坑和排查思路
4.1 跨域脚本报错信息被浏览器吃掉怎么办
之前说过跨域脚本的报错详情会被浏览器净化成一句Script error。但这个坑还有一个隐藏变体:即使你给自己域名下的脚本加了crossorigin="anonymous",如果服务器响应头里没有对应的Access-Control-Allow-Origin,浏览器会直接阻止脚本执行,这时候错误虽然能被捕获到,但脚本已经完全不能工作了,比“报错但能跑”更糟糕。
所以我的建议是:第三方脚本如果暂时拿不到 CORS 支持,就别强行加crossorigin属性,让它保持原样加载,至少功能还能用。监控层面退而求其次,通过event.target.src记录“某个跨域脚本报错了”,后续定向排查即可。给自己域名下的脚本加crossorigin则是推荐做法,因为你可以同时控制脚本资源和响应头,能拿到完整的报错堆栈,定位问题效率高很多。
4.2 动态创建的图片和未挂载节点捕获不到
还有一个特别容易被忽视的场景:用new Image()或者document.createElement('img')创建的图片,如果没有把元素插入到 DOM里,而是直接设置src去预加载,那么它加载失败时,事件根本不会经过 window 的捕获阶段——因为事件传播路径是根据元素是否在文档树里来决定的。一个不在文档里的元素,它的 error 事件只会在自己身上触发,不会冒泡也不会被全局监听器捕获到。
这就会造成一种假象:你在页面上用代码预加载了几张图片,其中一张 404 了,全局监控一点反应都没有。排查时一定先确认这个图片节点是不是真的挂在了 DOM 上。如果确实需要监控这种非挂载节点的加载失败,唯一的办法是在创建节点时显式绑定:
const img = new Image(); img.onerror = function () { sendReport({ type: 'image_preload_error', src: img.src }); }; img.src = 'https://cdn.example.com/banner/001.png';这类预加载图片通常是首屏关键图,失败的影响比较大,所以我建议宁可多写几行绑定代码,也不要依赖全局捕获。
4.3 CSS 背景图加载失败不会触发 error 事件
这个坑我敢说大部分人都不知道。如果你用 CSSbackground-image设置背景图,背景图请求 404 时,不会触发任何 error 事件。因为背景图不是 DOM 元素,浏览器的资源加载错误机制对这种 CSS 资源是静默的。你需要通过别的途径去发现,比如用 PerformanceObserver 去监听resource类型条目,检查请求的响应状态。
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.initiatorType === 'css' && entry.responseStatus >= 400) { console.warn('CSS 资源加载失败', entry.name, entry.responseStatus); } } }); observer.observe({ type: 'resource', buffered: true });这里注意,responseStatus这个字段在跨域资源上同样可能被隐藏,而且 PerformanceObserver 的兼容性和数据格式在不同浏览器里有差异,所以它更适合作为辅助手段。我的经验是:核心图片尽量用 img 标签而不是背景图,这样既能利用全局 error 捕获,也方便做懒加载和降级。
4.4 常见问题速查表
| 现象 | 原因 | 处理建议 |
|---|---|---|
| addEventListener 捕获不到图片错误 | 没传第三个参数true,监听的是冒泡阶段 | 改成window.addEventListener('error', handler, true) |
| window.onerror 收不到资源加载失败 | 混淆了运行时报错与资源错误 | 资源错误用 addEventListener + 捕获阶段,运行时报错用 window.onerror |
| 同一个报错上报了两遍 | 同时挂了 window.onerror 和 addEventListener | 上报去重,或只保留 addEventListener 方案 |
| 跨域脚本只有 Script error | 浏览器安全净化,缺少 CORS 配合 | 加 crossorigin 属性 + 服务器 CORS 头,或接受降级只记录 src |
| new Image() 失败没上报 | 节点未挂载到 DOM,事件不走 window 捕获路径 | 创建时显式绑定 onerror |
| CSS 背景图失败没上报 | 非 DOM 资源不触发 error 事件 | 结合 PerformanceObserver 辅助发现 |
| iframe 内错误看不到 | 跨域 iframe 事件不可见 | iframe 内部单独监听上报,或接受盲区 |
| 同一资源刷屏上报 | 网络抖动导致反复触发 | 在监听器里做限频去重 |
这张表基本覆盖了我做全局捕获这几年以来遇到的绝大部分问题。排查时先对照现象定位原因,再决定改哪里,效率会高很多。
最后再分享一个个人体会:全局捕获这段代码,写起来不难,但它属于“上线前必须布好、出事时才想起已经晚了”的基础设施。别等项目线上跑了一个月再来补,那时候你手里根本没有历史数据去对比排查。我现在的做法是,每次新项目搭前端骨架时,第一时间就把这套资源监控挂上,版本号也一并明确,这样后续一旦出现线上问题,能立刻从数据里看到是从哪个版本开始的,排查链路会顺畅很多。另外一个建议是把这套捕获逻辑单独抽成一个工具库或模块,和业务代码解耦,这样不管项目怎么迭代,监控代码都不会被不小心改坏。