页面报错了用户知道你不知道:手写一个前端异常上报脚本
代码打包之后跑在用户的浏览器里,出问题和本地调试完全是两回事。用户那边看到的是白屏、按钮点了没反应、提交转圈转半天,你能拿到的往往只有客服转述的一句"页面打不开"。缺的是现场信息:哪一行报的、什么浏览器、什么版本、报错前停在哪个页面。
本地 console 里的堆栈在这种场景下一文不值,因为错误发生在别人的机器上。好消息是浏览器已经把错误"广播"出来了——window 上的 error 事件、unhandledrejection 事件都在那儿,我们只要挂上监听、把有用的字段收一收、发回自己的服务端就行。整套东西一个文件、一百来行就能跑起来,不需要引任何第三方 SDK。
本文按"三类错误分别怎么抓 → 封装成一个脚本 → 怎么发出去 → 怎么防刷屏 → 服务端怎么接"这条线讲,最后给一份能直接复制用的单文件脚本和一个 Node 接收接口。涉及浏览器 API 的具体行为,以 MDN 和官方文档为准。

一、三类错误,要用三种方式抓
前端要收的错误主要分三类,触发机制不一样,只挂一个 window.onerror 是收不全的。这是很多人做的监控脚本数据对不上的第一个原因。
| 错误类型 | 典型场景 | 正确抓法 |
|---|---|---|
| 同步运行时错误 | 读了 undefined 的属性、调了不存在的函数 | window.onerror |
| Promise 未捕获拒绝 | 接口 reject 了没人 catch、async 函数里抛错 |
unhandledrejection 事件 |
| 资源加载失败 | img / script / css 404、CDN 挂了 | 捕获阶段的 error 事件 |
同步错误:window.onerror
这是最老牌的一个钩子,五个参数一次性给全:
window.onerror = function (message, source, lineno, colno, error) {
// message 错误信息的字符串
// source 出错脚本的 URL
// lineno 行号 colno 列号
// error Error 对象,堆栈(stack)要从这里取
console.log(message, source, lineno, colno, error && error.stack)
return false
}
几个容易被忽略的点:第五个参数 error 不是所有浏览器、所有场景都给,取之前要判空;返回值 false 表示"我不处理,按浏览器默认行为继续"(控制台照常打印),返回 true 则会把错误吞掉,本地调试时反而找不到问题,一般返回 false。
Promise 拒绝必须单独监听
onerror 抓不到未处理的 Promise 拒绝。下面这行代码跑起来,window.onerror 一声不响:
Promise.reject(new Error('接口挂了')) // 没有任何 catch
所以要单独挂一个事件:
window.addEventListener('unhandledrejection', function (e) {
const reason = e.reason
// reason 不一定是 Error 对象,也可能是字符串、数字,取值要做兼容
const message = reason && reason.message ? reason.message : String(reason)
const stack = reason && reason.stack ? reason.stack : ''
console.log('未处理的拒绝:', message, stack)
// e.preventDefault() 可以阻止控制台那条 "Uncaught (in promise)" 警告
})
线上最常见的漏报就出在这儿:异步请求失败、JSON.parse 崩了、await 后面的异常没人接,这些全都走 unhandledrejection。只写了 onerror 的话,这类错误一条都收不上来。
资源加载失败要在捕获阶段拿
<img>、<script>、<link> 加载失败时也会触发 error 事件,但这个事件不冒泡,所以用普通的 window.addEventListener('error', fn)(冒泡阶段)收不到。必须把第三个参数传 true,走捕获阶段:
window.addEventListener('error', function (e) {
// 脚本运行时错误会走 onerror,这里只处理资源节点,用 ErrorEvent 区分开
if (e instanceof ErrorEvent) return
const el = e.target // 出错的元素本身
if (!el || el === window) return
console.log(el.tagName, el.src || el.href) // IMG https://cdn.xxx/a.png
}, true) // true = 捕获阶段
这里 e.target 是 DOM 元素,el.src(img/script)或 el.href(link)就是失败的资源地址,比 JS 错误还直观——CDN 挂了、图片路径拼错了,看这个一眼就知道。
二、封装成一个单文件脚本
把上面三块加上上报逻辑组合起来,就是一个能直接用的监控脚本。存成 monitor.js,在业务脚本之前引入(越早越好,否则它加载前报的错会漏掉):
<script src="/monitor.js"></script>
完整脚本如下:
/* monitor.js —— 单文件前端异常监控,直接 script 引入即可 */
(function (global) {
'use strict'
var CONFIG = {
url: '/api/monitor/error', // 上报地址,换成你自己的
release: 'v1.0.0', // 版本号,每次发版改一下
sampleRate: 1, // 采样率 0~1,1 = 全量;流量大就调成 0.1
maxRepeat: 20, // 同一个错误最多上报几次,防刷屏
maxBuffer: 10, // 攒够多少条立刻发一次
flushInterval: 5000, // 最迟多久发一次(毫秒)
maxStack: 1000 // 堆栈最长保留多少字符
}
var buffer = [] // 待发送队列
var counter = Object.create(null) // 去重计数:key -> 已出现次数
var timer = null
// 采集逻辑里绝对不能抛错,否则会二次触发 onerror,直接死循环
function safe (fn) {
return function () {
try { return fn.apply(this, arguments) } catch (e) { /* 静默 */ }
}
}
function trim (str, len) {
str = String(str == null ? '' : str)
return str.length > len ? str.slice(0, len) : str
}
// 用「类型 + 消息 + 堆栈第一行」拼出指纹,用于去重
function fingerprint (item) {
var first = ''
if (item.stack) {
var lines = String(item.stack).split('\n')
first = lines.length > 1 ? lines[1] : lines[0]
}
return item.type + '|' + trim(item.message, 200) + '|' + trim(first, 200)
}
function send (payload) {
var body = JSON.stringify(payload)
// 首选 sendBeacon:浏览器排队发送,页面关了也能发出去,且不阻塞跳转
if (typeof Blob === 'function' && global.navigator &&
typeof global.navigator.sendBeacon === 'function') {
// 用 text/plain 是为了不触发 CORS 预检,服务端按纯文本读出来再 JSON.parse
var blob = new Blob([body], { type: 'text/plain;charset=UTF-8' })
if (global.navigator.sendBeacon(CONFIG.url, blob)) return
}
// 兜底一:keepalive 的 fetch,同样能在页面卸载后继续发
if (typeof global.fetch === 'function') {
global.fetch(CONFIG.url, {
method: 'POST',
keepalive: true,
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
body: body
}).catch(function () {})
return
}
// 兜底二:异步 XHR。一定不能改成同步,同步 XHR 会把主线程卡死
var xhr = new XMLHttpRequest()
xhr.open('POST', CONFIG.url, true)
xhr.setRequestHeader('Content-Type', 'text/plain;charset=UTF-8')
xhr.send(body)
}
function flush () {
if (timer) { clearTimeout(timer); timer = null }
if (!buffer.length) return
var events = buffer.slice()
buffer.length = 0
send({ release: CONFIG.release, events: events, sentAt: new Date().toISOString() })
}
function report (item) {
if (Math.random() > CONFIG.sampleRate) return // 采样:不在样本里直接丢
item.ts = new Date().toISOString()
item.page = location.href // 上下文:页面地址
item.ua = navigator.userAgent // 上下文:浏览器标识
item.release = CONFIG.release // 上下文:版本号
var key = fingerprint(item)
counter[key] = (counter[key] || 0) + 1
if (counter[key] > CONFIG.maxRepeat) return // 同一个错误超过次数就不再报
if (item.stack) item.stack = trim(item.stack, CONFIG.maxStack)
buffer.push(item)
if (buffer.length >= CONFIG.maxBuffer) { flush(); return }
if (!timer) timer = setTimeout(flush, CONFIG.flushInterval)
}
// ① 同步运行时错误
var prevOnError = global.onerror // 别把别人已经装的 handler 覆盖掉
global.onerror = safe(function (message, source, lineno, colno, error) {
report({
type: 'js',
message: String(message),
source: source || '',
lineno: lineno || 0,
colno: colno || 0,
stack: error && error.stack ? String(error.stack) : ''
})
if (typeof prevOnError === 'function') return prevOnError.apply(this, arguments)
return false // false = 不吞错误,控制台照常打印
})
// ② Promise 未捕获拒绝
global.addEventListener('unhandledrejection', safe(function (e) {
var reason = e.reason
report({
type: 'promise',
message: reason && reason.message ? String(reason.message) : String(reason),
stack: reason && reason.stack ? String(reason.stack) : ''
})
}))
// ③ 资源加载失败(必须捕获阶段,第三个参数 true)
global.addEventListener('error', safe(function (e) {
if (typeof ErrorEvent === 'function' && e instanceof ErrorEvent) return
var el = e.target
if (!el || el === global) return
report({
type: 'resource',
tag: el.tagName,
message: '资源加载失败: ' + (el.src || el.href || ''),
source: el.src || el.href || ''
})
}), true)
// ④ 页面要关了,赶紧把队列发出去
global.addEventListener('pagehide', safe(flush))
document.addEventListener('visibilitychange', safe(function () {
if (document.visibilityState === 'hidden') flush()
}))
})(window)
验证它是否正常工作很简单,在控制台敲三行,然后看 Network 面板有没有发出请求:
setTimeout(function () { null.foo }, 0) // 触发 js 类型
Promise.reject(new Error('接口挂了')) // 触发 promise 类型
new Image().src = '/this-image-not-exist.png' // 触发 resource 类型
三、上报为什么用 sendBeacon 而不是 ajax
上报这件事的难点在于时机很尴尬:错误经常发生在页面即将关闭、或者用户马上要跳转的那一刻。这时候如果用普通的 XMLHttpRequest 异步请求,浏览器可能直接把它取消掉,请求根本没发出去页面就没了。
早年的做法是同步 XHR(xhr.open('POST', url, false)),请求发完才继续执行。它能发出去,代价是把主线程整个卡住,用户点跳转要卡几百毫秒甚至更久,是明确被废弃的做法。
navigator.sendBeacon(url, data) 就是为这个场景设计的:浏览器把数据放进自己的发送队列,立刻返回,由浏览器在后台负责发完,页面关掉也不影响,还不阻塞跳转。它的返回值是布尔值,true 表示成功入队,false 表示队列满了或被拒绝——所以脚本里判断了返回值,false 就往下走 fetch 兜底。
// 两条兜底路径
fetch(url, { method: 'POST', keepalive: true, body: body }) // keepalive:卸载后仍尽量发完
fetch 的 keepalive: true 语义和 Beacon 接近,但有个 64KB 的请求体上限(Beacon 的整体大小限制也大致在这个量级,各浏览器实现略有差异,以官方文档为准)。所以脚本里对堆栈做了截断(maxStack),一条错误控制在 1KB 以内是安全的。
还有一个细节值得说:脚本用 Blob 把 Content-Type 设成了 text/plain;charset=UTF-8,而不是 application/json。原因是后者属于非简单请求,跨域时会先发一个 OPTIONS 预检,而预检请求在页面卸载场景下更容易失败。用 text/plain 走简单请求,服务端按文本读出来再 JSON.parse 就行,代价只是服务端多写一行。
四、去重、采样与上下文
这三件事决定你收到的报警是"有价值的信息"还是"一堆噪音"。
去重是最先要做的。一个渲染循环里的错误一秒能触发几十次,全量上报直接把上报接口打爆,而它们其实是同一个 bug。脚本用"类型 + 错误消息 + 堆栈第一行"拼成指纹,在内存里计数,超过 maxRepeat 次就不再上报。注意指纹里带堆栈第一行而不是整段堆栈,是因为整段堆栈在不同浏览器下格式不一,会让同一个错误算出多个指纹。
采样用最简单的随机数:
if (Math.random() > CONFIG.sampleRate) return // sampleRate = 0.1 就是 10% 采样
流量大的站点把 sampleRate 调到 0.1,用 10% 的样本推断整体错误率,性价比最高。注意采样要在去重之前做,否则计数会不准(脚本里就是这么排的)。
上下文决定了你能不能复现。一条错误只有 message 是没法定位的,脚本补了四个字段:发生时间 ts、页面地址 page、浏览器标识 ua、版本号 release。其中 release 最容易被忽略——没有版本号,你没法判断某个错误是新版本引入的还是一直存在的。
另外还有个批量策略:错误先进内存队列 buffer,攒满 10 条、或者距上次发送超过 5 秒、或者页面隐藏(visibilitychange)时 flush 一次。这样用户遇到连环报错也只会产生一两个请求,不会每个错误打一次接口。
五、服务端接收接口
接收端要做的很少:收下、解析、落盘。用 Express 写就几十行:
// server.js —— 异常上报接收端
const express = require('express')
const fs = require('fs')
const app = express()
// 上报来的是 text/plain 文本,所以用 express.text 收,再自己 JSON.parse
app.post('/api/monitor/error', express.text({ type: '*/*', limit: '128kb' }), (req, res) => {
let payload
try {
payload = JSON.parse(req.body || '{}')
} catch (e) {
return res.status(400).send('bad json')
}
const events = Array.isArray(payload.events) ? payload.events : []
events.forEach((ev) => {
const line = `[${ev.ts || ''}] [${ev.type}] ${ev.release || '-'} ${ev.page || ''} ` +
`:: ${ev.message || ''} @ ${ev.source || ''}:${ev.lineno || 0}:${ev.colno || 0}\n`
fs.appendFile('errors.log', line, () => {}) // 生产环境换成写 ES 或云日志服务
console.log(line.trim())
})
res.status(204).end() // 204 无响应体,客户端不需要,省带宽
})
app.listen(3000, () => console.log('monitor server on http://localhost:3000'))
跑起来:node server.js。如果上报页面和这个接口不同源,需要配 CORS(装 cors 中间件并 app.use(cors())),具体用法以 Express 官方文档为准。
几个和上报端对得上的点:用 express.text({ type: '*/*' }) 而不是 express.json(),因为 Beacon 发过来是 text/plain;JSON.parse 必须包 try/catch,脏数据不能让接口 500;返回 204 而不是 200 + JSON,上报接口每秒可能被调用很多次,响应体纯属浪费。
六、几个注意点
第一,采集逻辑里绝对不能再抛错。window.onerror 的回调里如果自己报错,浏览器会再次触发 onerror,形成死循环,页面直接卡死。脚本里所有处理函数都用 safe() 包了一层 try/catch,这是必须的,不是可选项。
第二,跨域脚本只会报 “Script error.”。引用的第三方 JS(CDN 上的)报错时,浏览器出于安全策略只给一条没有堆栈的 Script error.。解决办法是给 <script> 加 crossorigin 属性,同时让 CDN 返回 Access-Control-Allow-Origin 响应头,两边都配才生效。
第三,压缩后的行列号没有意义。打包后的代码变量名是 a、b,行号指向压缩文件的第 1 行第 3 万列。要还原成源码位置必须靠 source map:把 map 文件传给监控平台,或者自己用 source-map 这个库在服务端还原。别指望 lineno/colno 直接告诉你源码哪一行错了。
第四,sendBeacon 有体积限制。单次发送的数据量上限大致在 64KB 量级(具体数值各浏览器不同,以官方文档为准),超了 sendBeacon 会返回 false。所以堆栈必须截断,别把整个对象 JSON.stringify 塞进去。同时建议把上报接口本身加上限流和简单鉴权,否则被人拿去刷,就成了新的故障源。
第五,别覆盖已有的 onerror。一个页面里可能有别的监控 SDK 也挂了 window.onerror,直接赋值会把它顶掉。脚本里先存了 prevOnError,处理完再转调一次,多个监听器可以共存。
七、总结
前端异常监控的核心就三步:挂对钩子、发得出去、别刷屏。钩子要三个都挂——window.onerror 收同步错误、unhandledrejection 收 Promise 拒绝、捕获阶段的 error 收资源失败,少一个就有一类错误永远看不到。发送优先用 navigator.sendBeacon,它在页面卸载时仍然可靠且不阻塞跳转,失败再用 fetch(keepalive) 兜底,同步 XHR 千万别碰。端上还要做指纹去重、随机采样、补齐 URL/UA/版本这些上下文,否则收上来的就是一堆没法用的噪音。服务端用 express.text 收文本、JSON.parse 后落盘、返回 204,几十行就够。剩下的坑是死循环防护、跨域脚本的 Script error、source map 还原和体积限制。
- 点赞
- 收藏
- 关注作者
评论(0)