news 2026/9/29 15:03:57

前端异常监控体系搭建:捕获、格式化到上报的完整方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端异常监控体系搭建:捕获、格式化到上报的完整方案

前两天线上出了个问题:用户点某个按钮页面直接白屏,群里反馈了好几条消息,我在本地试了半天也没复现。最后查日志才发现,错误在 low-end 机型上偶发,而代码里唯一留下的线索就是一行console.log(error)。问题是,这行日志打在用户的浏览器里,我根本看不见。

那之后我就下决心重做了一遍前端异常捕获和上报链路:捕获所有能捕获的错误、统一格式化、服务端接收落库、按错误指纹聚合告警。整套流程跑通之后,线上问题从“用户说了才知道”变成了“错误上报先到,用户反馈后到”。这篇文章就把这套方案的完整思路和实操步骤拆开讲清楚,面向的是前端开发、全栈工程师,以及团队里打算搭监控体系的同学。内容基于我自己的落地经验,有些细节是常规文档里不会写的,建议边看边比对你现在的项目。

1. 为什么需要一套统一的前端异常捕获方案

先说一个现状:绝大多数前端项目不是没有错误处理,而是处理得太散。try/catch包一层、console.log(error)打一行、框架的errorHandler里alert一下,各管各的。结果就是错误信息散落在不同地方,格式五花八门,线上出了事没有一条能直接定位根因的完整链路。

我见过最典型的场景是这样的:生产环境开了压缩和混淆,console.log(error)打出来的 stack 是一长串压缩后的单字母变量名,根本看不出是哪个模块抛的错。更麻烦的是,有些异步错误根本没被try/catch包住,连日志都没打,直接静默失败。用户以为操作失败了,其实后端收到请求了,只是前端渲染环节崩了,这种错误最坑人。

所以我认为,一套靠谱的前端异常捕获方案至少要满足三个条件:捕获范围完整、格式统一、数据能上报落库。捕获范围完整,是指普通的运行时错误、未处理的 Promise 异常、框架组件层级错误、资源加载失败都要能接住;格式统一,是指不管错误来源是哪条链路,最终上报的数据结构都是一套,字段名一致、类型一致、含义一致;数据能上报落库,是让错误日志从浏览器走到服务端,再进入日志系统或监控平台,这样才谈得上统计、告警和事后复盘。

这套方案做下来之后,收益非常直接:错误可以被归类了,可以知道哪个接口出错最频繁,哪个页面崩溃率最高,哪个版本上线后错误量突增。配合 Source Map,报错信息甚至能精确到源码的某一行。这些能力,靠console.log(error)是永远拿不到的。

1.1 console.log 到底哪里不够用

console.log(error)不是完全没用,它适合开发调试,但不适合线上问题排查,原因有三个。

第一,本地打得出来,线上看不见。用户浏览器里的日志不会自己跑到你的电脑上,除非用户愿意打开控制台把内容复制给你,而绝大多数用户不会这么做。线上问题靠用户口头描述“页面点了没反应”,你猜不出是哪一行代码抛的错。

第二,信息维度太单薄。console.log(error)通常只打了错误对象本身,但定位一个问题往往需要更多上下文:用户当时在哪个页面、做了什么操作、浏览器版本是多少、接口返回了什么数据。这些信息在 console 里完全没有,而在异常上报体系里是可以自动附带上的。

第三,不可聚合、不可对比。两个用户报了同一个错误,你通过 console 日志只能看到两条孤立的记录,看不出这个错误的整体影响面。错误上报之后可以做聚合统计:同一个错误指纹在 24 小时内出现了多少次、分布在哪些页面、哪些版本。有了这些数据才能排优先级,才敢说“这个 bug 影响面大,必须立刻修”。

1.2 统一格式化解决的核心问题

统一格式化听起来像是一个不起眼的工程细节,实际上它是整套方案里承上启下的关键节点。捕获是入口,上报是出口,格式化是中间的转换层。这一层做得不好,上面的捕获接了再多错误,下面的服务端也收得乱七八糟。

我用一个真实例子说明格式不统一的痛点。团队里同时有 Vue 和 React 两个项目,Vue 的errorHandler拿到的错误对象结构是一套,React 的ErrorBoundary拿到的又是另一套;window.onerror回调的参数是message, source, lineno, colno, error五个平铺参数,而unhandledrejection回调里拿到的是一个包含reason属性的对象。如果不做统一格式化,服务端接到的数据就有五六种结构,写统计脚本的时候光是兼容这些格式就能劝退你。

统一格式化把问题收敛成“一套字段标准 + 一个格式化函数 + 一份序列化逻辑”。不管错误来自哪条链路,格式化函数最终输出的是同一个 JSON 结构,例如{ eventType, errorType, message, stack, filename, lineno, colno, timestamp, url, userAgent, ... }。这样下游消费数据的人——无论是写监控告警的、做数据可视化的、还是写自动归因脚本的——都只需要对接一套结构。

我自己的体会是,格式化层的代码量不大,收益却非常持久。它就像团队内部的接口协议,定好之后大家按协议走,新增一个捕获源只需要适配到协议,不需要再改服务端。

2. 异常捕获的四条主链路,一条都不能少

前端运行环境的错误来源比大多数人想像的要多。我把日常项目里必须接住的错误来源归纳成四条主链路:普通运行时错误、未处理的 Promise 异常、框架组件生命周期内的错误、资源加载失败。这四条链路在 js 引擎层面的触发机制不同,捕获方式也各不相同,如果只接了其中一条,线上仍然会有漏网之鱼。

2.1 window.addEventListener('error') 捕获普通运行时错误

全局监听error事件是捕获普通运行时错误最基本的手段。需要明确一点,window.onerror和window.addEventListener('error', handler)的细节是有差异的,前者是 DOM 0 级事件处理,后者是 DOM 2 级事件监听,我建议用addEventListener,它不会覆盖页面上其他逻辑对 error 事件的处理,可以共存多个监听器。

先看基础代码:

// 注意第三个参数传 true,使用捕获阶段 window.addEventListener('error', function (event) { // 先判断是不是资源加载错误 const target = event.target; const isResourceError = target && (target.tagName === 'SCRIPT' || target.tagName === 'LINK' || target.tagName === 'IMG'); if (isResourceError) { // 资源加载错误单独走一条逻辑,下面会讲 handleResourceError(event); return; } const error = event.error || {}; const stack = error.stack || ''; // 统一格式化后上报 reportError(createErrorInfo({ eventType: 'error', errorType: error.name || 'Error', message: error.message || String(error), stack: stack, filename: event.filename, lineno: event.lineno, colno: event.colno, })); }, true);

这里有几个细节值得特别说。

第一,必须传true使用捕获阶段。如果不传,默认是冒泡阶段,某些错误可能不会冒泡到 window。资源加载错误其实不会冒泡,只有在捕获阶段才能被全局监听到。

第二,回调函数里event.error不一定存在。老版本浏览器或者某些跨域场景下,错误事件对象上拿不到错误对象,只能拿到message。所以代码里要做兜底,用error.stack || ''避免访问不存在属性时报二次错误。

第三,event.filename、event.lineno、event.colno这三个字段只在脚本运行时错误才有意义,资源加载错误里它们通常是空值或 0,所以要单独判断。

2.2 unhandledrejection 捕获未处理的 Promise 异常

Promise 异步异常是前端错误里最容易漏掉的一类。ES8 的async/await普及之后,很多人默认有了async/await就不需要try/catch了,结果就是 Promise 链里抛出的异常如果没人接,会触发unhandledrejection事件,而window.onerror是捕获不到这种异常的。

处理unhandledrejection的标准姿势:

window.addEventListener('unhandledrejection', function (event) { const reason = event.reason || {}; const error = reason instanceof Error ? reason : new Error(String(reason)); const stack = error.stack || ''; reportError(createErrorInfo({ eventType: 'unhandledrejection', errorType: error.name || 'PromiseRejectionError', message: error.message || String(reason), stack: stack, // unhandledrejection 事件没有 filename/lineno,需要从 stack 里尽可能解析 filename: parseFilenameFromStack(stack), lineno: parseLinenoFromStack(stack), colno: parseColnoFromStack(stack), })); }, true);

这里有一个非常关键的细节:event.reason不一定是 Error 对象。很多代码里直接Promise.reject('some string'),或者reject({ code: 500, msg: 'server error' }),这种情况下reason是个字符串或普通对象,直接reason.stack会取到undefined,甚至直接抛 TypeError。

所以我把非 Error 类型的 reason 统一包装成new Error(String(reason))。不过要注意,String({ code: 500, msg: 'server error' })得到的是[object Object],这对排查问题几乎没有帮助。更好的做法是自己实现一个序列化方法,把对象里的关键字段展开成可读字符串,比如code=500,msg=server error。这一点我在下面的统一格式化章节里会再展开。

另外,unhandledrejection的filename/lineno/colno并不是事件对象的标准属性,我自己是从 stack 字符串里正则解析的。不同浏览器的 stack 格式略有差异,Chrome 是at functionName (file:///path/file.js:10:20),Firefox 是functionName@file:///path/file.js:10:20,写解析函数时建议两个正则都匹配。

2.3 框架层错误钩子:Vue 和 React 的实现差异

全局error事件捕获的是运行时错误,但框架内部的生命周期错误需要框架自身提供捕获机制。原因在于,Vue 和 React 在渲染、更新、生命周期钩子中发生的错误,如果只在框架内部处理,不一定会冒泡到 window。而且在框架层捕获还能拿到组件实例、组件名这些业务上下文,这对定位问题帮助极大。

Vue 3 里可以在创建应用实例时配置errorHandler:

import { createApp } from 'vue'; const app = createApp(App); app.config.errorHandler = (err, instance, info) => { // err: 错误对象 // instance: 发生错误的组件实例 // info: 错误来源信息,例如 'setup function' / 'render function' / 'lifecycle hook' reportError(createErrorInfo({ eventType: 'vue-error', errorType: err.name || 'VueError', message: err.message || String(err), stack: err.stack || '', componentName: instance ? instance.$options.name || instance.$options.__name : '', lifecycleInfo: info, })); };

Vue 2 里同样可以在构造函数上配置,但要注意 Vue 2 的errorHandler默认不会捕获errorCaptured之外的生命周期钩子错误,配置方式稍有差异。我在实践里遇到过一个典型问题:早期把window.addEventListener('error')和 VueerrorHandler同时接上了,结果同一个错误被上报了两次。原因就是 Vue 内部的错误在被框架处理之后,仍然会触发全局 error 事件。解决方案是在全局 error 处理器里用一个标记位过滤掉“已经被框架处理过”的错误,下面常见问题里我会专门写。

React 的情况不太一样。React 错误边界是组件化的,你得用一个类组件包住需要保护的子树:

import React from 'react'; class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, info) { // info 里包含 componentStack,可以定位到具体组件树位置 reportError(createErrorInfo({ eventType: 'react-error', errorType: error.name || 'ReactError', message: error.message || String(error), stack: error.stack || '', componentStack: info.componentStack || '', componentName: parseComponentNameFromStack(info.componentStack), })); } render() { if (this.state.hasError) { return this.props.fallback || <div>页面出错了</div>; } return this.props.children; } }

注意几个 React 特有的细节。错误边界只能捕获生命周期方法、render 方法、构造函数里的错误,捕获不了事件处理器里的错误——事件处理器里的错误会直接抛到全局,需要靠window.addEventListener('error')兜底。这跟 Vue 的errorHandler相比覆盖范围小一些,实际项目中建议两层都接,互为补充。

React 18 之后的createRoot渲染出来的应用,配合并发特性,开发模式下某些错误会在错误边界里被特殊处理,行为跟生产环境不完全一致。我在调试时踩过这个坑,如果发现 dev 环境错误边界不生效,先切到生产构建再复现。

2.4 资源加载错误与其他边界情况

资源加载错误和脚本执行错误是两类完全不同的错误。脚本执行错误发生在 js 引擎里,事件对象上会有error属性、lineno、colno;资源加载错误发生在网络层,事件的target指向的是出错的<script>、<img>、<link>等标签,事件类型是error,但没有error对象。

资源加载错误在捕获阶段更容易拿到,判断方式看 target 类型:

window.addEventListener('error', function (event) { const target = event.target; if (!target) return; const isResource = target.tagName === 'SCRIPT' || target.tagName === 'LINK' || target.tagName === 'IMG' || target.tagName === 'IFRAME' || target.tagName === 'VIDEO' || target.tagName === 'AUDIO'; if (isResource) { reportError(createErrorInfo({ eventType: 'resource-error', errorType: 'ResourceError', message: `资源加载失败: ${target.tagName} ${target.src || target.href || ''}`, resourceUrl: target.src || target.href || '', resourceTag: target.tagName, })); } }, true);

边界情况里还有一个容易被忽略的点:跨域脚本抛出的错误,浏览器为了保护安全会向全局错误处理机制暴露一个只包含Script error.的字符串,错误对象拿不到堆栈和原始信息。这个问题我在常见问题章节里单独讲解决办法。

另外还要考虑iframe内的错误。主页面和 iframe 是不同的全局作用域,iframe 内的错误不会冒泡到主页面。如果你有嵌入第三方 iframe 的场景,要么在 iframe 页面内部自己埋点上报,要么用postMessage把错误信息从 iframe 传回主页面。这个我没有在项目里全部铺开,因为涉及跨域权限,但思路是清晰的。

3. 统一格式化:把错误变成结构化的上报数据

捕获链路接住错误之后,下一步就是格式化。这一层做得好不好,直接决定服务端接到的数据可不可用。我在项目里遇到过一种典型情况:服务端接到了上报,但错误字段里混着[object Object]、undefined、超长堆栈,统计时还得先做一遍清洗。这些问题的根源都在格式化层。

3.1 序列化 Error 对象的坑与标准字段设计

先说Error对象序列化的坑。你直接JSON.stringify(error)是拿不到message和stack的。原因是Error的message和stack属性默认是 non-enumerable 的,JSON.stringify只序列化可枚举属性,所以输出基本是{}。推荐用一个专门的格式化函数把错误字段显式提取出来。

我用的标准字段结构大概是这样的:

function createErrorInfo(options) { const timestamp = Date.now(); return { // 事件类型:error / unhandledrejection / vue-error / react-error / resource-error eventType: options.eventType || 'error', // 错误类型:TypeError / ReferenceError / VueError ... errorType: options.errorType || 'Error', // 错误信息 message: truncate(options.message || '', 300), // 堆栈 stack: truncate(options.stack || '', 2000), // 源码定位信息 filename: options.filename || '', lineno: options.lineno || 0, colno: options.colno || 0, // 业务上下文 componentName: options.componentName || '', lifecycleInfo: options.lifecycleInfo || '', // 环境信息 url: location.href, userAgent: navigator.userAgent, timestamp, pageId: getPageId(), userId: getUserId(), }; }

字段设计要遵循几个原则。第一,所有字段都有默认值,不允许上报的数据里有undefined或null,否则服务端解析和后续统计都会遇到麻烦。第二,message和stack要截断。stack在复杂的应用里可能有几十 KB,全量上报会撑爆日志系统,也影响传输效率。我通常把 message 截到 300 字符以内,stack 截到 2000 字符以内。第三,固定加timestamp,不要依赖服务端接收时间,因为上报可能有延迟和批量提交,错误发生时间和到达时间不是一回事。

3.2 非 Error 对象序列化与请求上下文的自动补充

unhandledrejection里的reason和部分框架错误钩子拿到的错误可能是任意值,这一层要单独做序列化。我建议写一个单独的stringifyReason函数:

function stringifyReason(reason) { if (reason instanceof Error) { return reason.message; } if (typeof reason === 'string') { return reason; } if (reason && typeof reason === 'object') { try { return JSON.stringify(reason); } catch (e) { // 循环引用等场景,退回手动提取 return Object.keys(reason).map(key => `${key}=${String(reason[key])}`).join(','); } } return String(reason); }

这里有个细节我特别想强调:如果reason是一个包含循环引用的对象,JSON.stringify(reason)会抛异常,所以在 try/catch 里包一层,异常时退回去手动提取第一层字段。

请求上下文补充是格式化层的增值点。我在项目里维护了一个全局的最近操作队列,记录用户最近发生的交互行为:

const actionQueue = []; function trackAction(action) { actionQueue.push({ action: action, timestamp: Date.now(), url: location.href, }); if (actionQueue.length > MAX_ACTIONS) { actionQueue.shift(); } } // 在点击、路由切换、接口请求时调用 document.addEventListener('click', function (event) { const target = event.target; if (target && target.tagName) { trackAction(`click ${target.tagName.toLowerCase()}${target.className ? '.' + target.className : ''}`); } });

这样上报的时候可以把最近的 10 条操作记录一起带上去,排查“用户做了什么操作之后触发报错”时会非常有用。这个思路在成熟监控平台里叫“用户行为追踪”,但自己实现一个轻量版本并不复杂,代码量很小,收益很明显。

3.3 Source Map 让线上堆栈还原到源码

生产环境的代码经过压缩混淆之后,stack 里的路径通常是https://cdn.example.com/app.8f3k2l.js:1:23345。一行 2 万多个字符,根本没法定位。Source Map 的作用就是把压缩后的行列号映射回源码文件的行列号。

实际落地有两种方式。一种是在浏览器端拿 stack 里的行列号,配合线上可访问的 source map 文件,用 source-map 库还原,还原后在浏览器端直接上报源码位置。另一种是上报压缩后的行列号,服务端保留 source map 文件,收到上报后再做还原。我偏向第二种:source map 文件通常比较大,发布到公网有一定源码泄露的风险,放在服务端内部处理更安全。而且服务端做还原,后续如果需要重新映射或者调整还原逻辑,不需要重新发版。

服务端还原的核心逻辑大致是这样的:

// Node.js 环境,使用 source-map 库 import { SourceMapConsumer } from 'source-map'; async function restoreStack(stackLine, mapFile) { const consumer = await new SourceMapConsumer(mapFile); const pos = consumer.originalPositionFor({ line: stackLine.line, column: stackLine.column, }); return { source: pos.source, line: pos.line, column: pos.column, name: pos.name, }; }

要把 stack 字符串逐行拆开,取每一行的 line/column 做还原,重建一份可读的堆栈。这个方案我在生产环境跑了大半年,效果是把“一行压缩代码”还原成“源码目录 + 函数名 + 行号”,定位效率提升了不是一星半点。

4. 从捕获到服务端上报的完整链路实现

前面几章解决了“错在哪”和“错得长什么样”,这一章解决“怎么把错误送到服务端”。上报链路看起来简单——发个请求而已——但里面涉及性能、可靠性和服务端配合的问题,不提前设计好,上线后会在关键时刻给你掉链子。

4.1 上报策略:防抖、批量、采样与通道选择

错误上报不能像写日志一样每题一条,用户在崩溃边缘连续报错时,接口会被打爆。我的做法是“本地缓存 + 定时批量上报”。

基本思路是维护一个上报队列,攒到一定数量或间隔一定时间就统一发送:

let reportQueue = []; let reportTimer = null; function reportError(errorInfo) { reportQueue.push(errorInfo); // 队列长度达到阈值,立即发送 if (reportQueue.length >= 10) { flushReportQueue(); return; } // 或者定时 5 秒发送 if (!reportTimer) { reportTimer = setTimeout(flushReportQueue, 5000); } } function flushReportQueue() { if (reportTimer) { clearTimeout(reportTimer); reportTimer = null; } if (reportQueue.length === 0) return; const payload = reportQueue.splice(0, reportQueue.length); if (navigator.sendBeacon) { const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); navigator.sendBeacon('/api/error/report', blob); } else { fetch('/api/error/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), keepalive: true, }); } }

这里我同时用了sendBeacon和fetch。sendBeacon的好处是即使用户关闭页面,浏览器也会在卸载期间尽量把请求发出去,适合错误上报这种需要在页面离开时确保送达的场景。但它有请求体大小限制,通常 64KB 以内都没问题。老浏览器没有sendBeacon,所以回退到fetch,并加上keepalive: true来提升页面卸载时的送达率。

采样策略也值得说。对于高流量应用,全量上报的成本不低,而且同一个错误可能被几百个用户同时触发,全量上报会造成大量重复。我用的策略是:普通错误全量上报,但同一错误指纹在短时间内(比如 1 分钟)最多上报 1 条;致命错误(白屏、页面崩溃)不做采样,全量上报。错误指纹可以用errorType + message + filename简单拼接生成。

4.2 服务端接收接口的参考实现

前端上报之后,服务端得有一个接口先接住数据。我用 Node.js + Express 写过一个简化的接收接口,核心逻辑是校验数据、落盘、写入后续处理的队列。

const express = require('express'); const bodyParser = require('body-parser'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(bodyParser.json({ limit: '1mb' })); app.post('/api/error/report', (req, res) => { const reports = Array.isArray(req.body) ? req.body : [req.body]; if (reports.length === 0) { res.status(400).json({ ok: false, message: 'empty' }); return; } const logLine = reports.map(item => JSON.stringify(item)).join('\n') + '\n'; const logFile = path.join(__dirname, 'error-log', `${getToday()}.log`); // 简化处理:直接追加写入文件 fs.appendFile(logFile, logLine, (err) => { if (err) { res.status(500).json({ ok: false }); return; } res.status(200).json({ ok: true }); }); }); function getToday() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } app.listen(8080);

写接口时有两个容易踩的坑。

第一个是 CORS。如果前端页面和后端接口不在同一个域名,浏览器跨域请求会被拦住。测试时可以直接在浏览器里访问接口发现没问题,但页面里发请求就会失败。开发环境用cors中间件放开,生产环境建议配置白名单域名。

第二个是请求体大小限制。错误堆栈加上下文,单条可能几十 KB,批量上报一次可能几百 KB。bodyParser.json({ limit: '1mb' })限到 1MB,防止有人恶意上报超大 body 打爆内存。超过限制的请求会直接 413,需要在前端把单次上报的条数控制住。

真实生产环境一般不会只把日志往文件里写,至少接一个日志采集管道或者写入数据库,方便后面查询和聚合。我在这套方案里用文件落盘做基础存储,额外写了一个轮询脚本把当天日志同步到 ClickHouse 再聚合,但那是另一个话题了,这里不展开。

4.3 上报数据的安全与隐私过滤

错误上报的数据里很容易夹带敏感信息。比如location.href如果带 query 参数,而 URL 里有 token 或手机号,就会跟着一起上报。又比如错误堆栈里可能包含函数参数,参数里有用户输入的内容。所以我做了两层过滤。

第一层是前端过滤。上报之前把 URL 里的敏感 query 参数替换成固定占位符:

function sanitizeUrl(url) { if (!url) return ''; try { const parsed = new URL(url); const sensitiveKeys = ['token', 'access_token', 'password', 'phone', 'account']; parsed.searchParams.forEach((value, key) => { if (sensitiveKeys.some(k => key.toLowerCase().includes(k))) { parsed.searchParams.set(key, '[REDACTED]'); } }); return parsed.toString(); } catch (e) { // URL 解析失败时退回到正则替换 return url.replace(/(token|access_token|password|phone|account)=[^&]+/gi, '$1=[REDACTED]'); } }

第二层是服务端过滤。收到上报数据之后,再根据一份敏感字段清单做一次清洗,只保留结构化字段和清理后的 message/stack。两层过滤的意义在于,前端过滤是减少传输泄露,服务端过滤是兜底,即使某些字段之前没考虑到,后端也能挡一层。

还有个很现实的隐私问题:用户上报数据里如果包含个人信息,存储时要做脱敏。我的实践是尽量不采集用户主动输入的内容,error 的 message 里如果包含,也会在服务端清洗时做替换。这块不用搞得太复杂,核心原则是“能少采集就少采集,能采集到的最小字段就采最小字段”。

5. 常见问题与排查技巧实录

方案落地过程中,我碰到了不少坑。有些坑是不跑一遍线上环境根本发现不了的。这里挑几个高频问题写出来,希望能帮你少走弯路。

5.1 错误上报发出的请求为什么没有到达服务端

这是我上线后第一个遇到的奇怪问题。页面确实报错了,上报函数也确实执行了,但服务端日志里一条都没有。排查下来发现原因在请求时机上:错误发生在页面 loading 阶段,请求还没发出去,页面就跳转走了。fetch请求被浏览器中断,或者压根没发出去。

解决办法有三个,按优先级排。第一,尽量用navigator.sendBeacon,它专门为“页面卸载时发送数据”设计。第二,fetch加keepalive: true,这个特性允许请求在页面卸载后继续传输。第三,把关键的初始化错误提前上报,不要攒在队列里等定时器触发,否则页面生命周期短的话,定时器还没到上报时间页面就关了。

我还遇到过另一个问题:本地测试接口明明通,线上却一条上报都没有。查到最后发现是 CSP 策略把上报接口的域名拦了。如果你的页面配置了 Content-Security-Policy 的connect-src,记得把上报接口的域名加到connect-src里,否则请求会被浏览器直接拦截,控制台还会打一条 CSP 相关报错。

5.2 跨域脚本报错 Script error 的排查与处理

跨域脚本错误是前端异常监控里最经典的老大难。如果你的代码里有部分静态资源托管在 CDN 上,而这些资源是通过<script src="https://cdn.example.com/app.js">引入的,浏览器为了安全不会把这个外域脚本的错误细节暴露给本域的全局错误机制。你捕获到的错误信息永远只有一句Script error.,没有堆栈、没有行列号、没有原始 message。

解决办法有两个,需要前后端配合。

一个是给外域脚本标签加上crossorigin="anonymous"属性,并且在 CDN 响应头里加上Access-Control-Allow-Origin: 你的域名。这样浏览器才会把错误细节暴露给全局错误处理器。

<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>

另一个是如果不方便改 CDN 响应头,就在打包时把脚本内容改成同域路径由网关转发,或者干脆把关键业务代码跟第三方脚本分离,第三方脚本的错误细节拿不到就算了,至少自己业务的错误能完整捕获。

我在项目里遇到的一个特殊情况是:crossorigin="anonymous"加了之后,某些老版本浏览器的 script 加载从缓存复用变成了需要重新校验响应头,导致行为差异。这个概率不高,但如果测试环境一直正常、线上频繁上报Script error.,可以检查这一层。

5.3 重复上报与循环上报的控制

前面提过的重复上报问题是这个方案的常见并发症。同一个错误可能同时被window.addEventListener('error')和框架errorHandler接住,导致上报两条相同的数据。控制方式有两种。

第一种是源头去重。在框架的errorHandler里处理错误之后设置一个模块级标记,全局 error 处理器看到这个标记就跳过:

let processedByFramework = false; // 全局 error 处理器入口 window.addEventListener('error', function (event) { if (processedByFramework) { processedByFramework = false; return; } // 真正的处理逻辑... }); // Vue errorHandler 里 app.config.errorHandler = (err, instance, info) => { processedByFramework = true; // 上报逻辑... };

这个方案有个隐患:Vue 处理错误之后,全局 error 事件里拿到的错误对象是同一个,标记位在同步代码里能生效,但如果框架内部处理和全局事件是异步的,标记位可能被其他错误“提前消费”。所以我更推荐第二种。

第二种是上报端去重。上报前根据错误指纹在本地存一份最近上报的时间戳,同一指纹在时间窗口内只上报一次。这个方案不用考虑框架内部的时序问题,更稳:

const dedupeMap = new Map(); function shouldReport(errorInfo) { const fingerprint = `${errorInfo.errorType}|${errorInfo.message}|${errorInfo.filename}:${errorInfo.lineno}:${errorInfo.colno}`; const now = Date.now(); const lastTime = dedupeMap.get(fingerprint) || 0; if (now - lastTime < 60 * 1000) { return false; } dedupeMap.set(fingerprint, now); // 控制 Map 大小,避免内存无限增长 if (dedupeMap.size > 200) { const oldestKey = dedupeMap.keys().next().value; dedupeMap.delete(oldestKey); } return true; }

这还不够。更隐蔽的问题是循环上报:上报逻辑本身可能有 bug,导致上报代码执行时再抛一个错误,又被全局错误机制捕获,又重新上报,形成一个死循环。我在项目里专门加了一道保险:上报函数内部用 try/catch 包住所有逻辑,并且设置一个模块级标志,一旦正在执行上报流程,所有错误处理函数直接返回,不再触发新的上报。这个“上报过程中禁止再进入上报”的约束,是监控类代码的基本要求。

5.4 实操中还踩过的几个细节坑

这里补充几个小坑,都是真实里会遇到但很少人写出来的。

第一个是async/await的 try/catch 吞错问题。很多人以为try/catch包住await就万事大吉,但如果你在 try 块里调用的函数内部自己 catch 了错误但没继续抛,外面的 catch 是接不到的。我建议团队里统一一个规范:内部函数不要吞错,把握不准的一律向上抛。

第二个是开发环境的干扰。开发模式下框架和 webpack 会额外加载很多 polyfill 和 dev 代码,行为跟生产环境差异非常大。调试时经常发现 dev 环境报错规则和生产不一致,我在项目里加了环境判断,只有生产环境才初始化上报逻辑:

if (import.meta.env.PROD || process.env.NODE_ENV === 'production') { initErrorTracking(); }

第三个是移动端低版本浏览器兼容。navigator.sendBeacon在 iOS 14 以下和部分安卓 WebView 里不可用,Promise.prototype.finally在旧浏览器也不一定支持。如果你要兼容这些环境,上报逻辑里要做能力检测,并做好 polyfill。

第四个是告警设置。错误上报到服务端之后,如果没有告警,那只是从“看不见”变成了“藏在日志文件里”。我在服务端加了个简单的聚合扫描,每分钟统计最近 5 分钟内上报量,如果超过基线阈值就发企业微信通知。这个告警阈值需要根据项目流量动态调整,刚开始设得低一些,宁可误报,不要漏报。

6. 方案落地的最后一点建议

整套方案从设计到跑通,我花了两周时间,核心代码量其实不大,但收益是持续的。线上错误从“不可见”到“可追踪、可聚合、可告警”,排查问题的平均耗时大幅缩短。如果你正准备在自己的项目里落地这套方案,我的建议是先做最小闭环:全局 error 捕获 + unhandledrejection 捕获 + 统一格式化 + 服务端接收日志。这四个环节跑通,就已经能覆盖绝大多数线上问题。

框架层捕获、Source Map 还原、用户行为追踪、告警这些增强能力,等最小闭环稳定运行之后再逐步加上去。上来就铺得很大,容易在各种边缘情况里消耗太多精力,反而坚持不下去。

最后分享一个我在实践中反复体会到的原则:错误监控系统的价值不在于代码多漂亮,而在于它能不能持续、稳定地把线上问题暴露出来。维护这套系统本身也是一份长期工作,新框架、新浏览器特性、新打包工具都会引入新的错误形态,它需要像业务代码一样持续迭代。你可以先从今天的一个按钮报错开始,把它完整地上报一次,后面慢慢扩展,就足够了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 15:03:01

AI;DR与Don‘t be a meat proxy:构建自动化技术摘要流水线

如果你每天的工作里有一项固定的动作&#xff1a;打开一篇技术文章&#xff0c;复制正文&#xff0c;贴到 AI 对话框里&#xff0c;让它总结&#xff0c;再把答案复制回文档或者群里。那么你有没有想过&#xff0c;这个“复制 — 粘贴 — 再复制”的循环里&#xff0c;真正不可…

作者头像 李华
网站建设 2026/9/29 14:59:17

C语言介绍(一)

一、C语言的概念 1.C语言是什么&#xff1f; 答&#xff1a;C语言是一种计算机语言&#xff0c;其他的计算机语言还有C/Java/GO/Python等。 2.C语言的历史 答&#xff1a;C语言最初是作为Unix系统开发工具而发明的。 3.编译器的选择-VS2022 3.1编译和链接 C语言是一门编译…

作者头像 李华
网站建设 2026/9/29 14:52:16

回形针最大化器:AI目标错位与防失控清单

如果你跟我一样长期在生产环境里调模型、改策略、定KPI&#xff0c;大概早就发现一个魔咒&#xff1a;凡是挂在后台的指标&#xff0c;最后都会被系统以各种方式"顶着涨"。我每次被这种问题折磨的时候&#xff0c;脑子里都会蹦出一个词&#xff1a;paperclip。准确地…

作者头像 李华
网站建设 2026/9/29 14:45:41

Qwen3.8 27B本地部署实战:从硬件评估到C++开发辅助

本地跑大模型&#xff0c;很多人的第一反应是&#xff1a;跑是能跑&#xff0c;但顶多写点打油诗、做个翻译&#xff0c;真让它干活就露馅了。这种印象在过去两年里被反复验证——7B、8B模型在通用对话上勉强够用&#xff0c;可一旦涉及 C 多文件工程、3D 建模脚本、完整小游戏…

作者头像 李华
网站建设 2026/9/29 14:43:31

多模态DeepSeek实战:图片理解、批量OCR与成本核算全解析

“多模态版 DeepSeek 长眼了&#xff0c;1000 张图只要 1 块钱”——这两天这个话题在各个技术群里传得比较快。先说结论&#xff1a;多模态不是玄学&#xff0c;它指的是模型能直接吃图片、截图、图表、文档&#xff0c;而不是只能读文字。对开发者来说&#xff0c;这意味着以…

作者头像 李华