页面报错了用户知道你不知道:手写一个前端异常上报脚本

举报
海是岛思念的泪 发表于 2026/10/03 15:33:34 2026/10/03
【摘要】 手写一个单文件前端异常监控脚本:window.onerror 收同步错误、unhandledrejection 收 Promise 拒绝、捕获阶段 error 收资源失败,用 sendBeacon 上报并以 fetch(keepalive) 兜底,配指纹去重、随机采样和上下文补全,另附 Express 接收接口。含死循环、Script error、source map、体积限制等坑。

代码打包之后跑在用户的浏览器里,出问题和本地调试完全是两回事。用户那边看到的是白屏、按钮点了没反应、提交转圈转半天,你能拿到的往往只有客服转述的一句"页面打不开"。缺的是现场信息:哪一行报的、什么浏览器、什么版本、报错前停在哪个页面。

本地 console 里的堆栈在这种场景下一文不值,因为错误发生在别人的机器上。好消息是浏览器已经把错误"广播"出来了——window 上的 error 事件、unhandledrejection 事件都在那儿,我们只要挂上监听、把有用的字段收一收、发回自己的服务端就行。整套东西一个文件、一百来行就能跑起来,不需要引任何第三方 SDK。

本文按"三类错误分别怎么抓 → 封装成一个脚本 → 怎么发出去 → 怎么防刷屏 → 服务端怎么接"这条线讲,最后给一份能直接复制用的单文件脚本和一个 Node 接收接口。涉及浏览器 API 的具体行为,以 MDN 和官方文档为准。
image.png

一、三类错误,要用三种方式抓

前端要收的错误主要分三类,触发机制不一样,只挂一个 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 还原和体积限制。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。