排查线上问题的时候,最怕听到的不是“出Bug了”,而是“你本地试试,我这边控制台没打出console.log”。用户在真实环境里遇到的错误,几乎不会主动打开控制台帮你复制堆栈,大多数时候只会丢来一句“页面打不开了”或者一张白屏截图。问题的根子在于,我们习惯把错误出口留在console.log(error),但这个出口只对开发者可见,对线上用户和监控体系完全透明。
这篇内容,我打算把“前端异常捕获与统一格式化”这条链路完整拆一遍:从异常类型的梳理、window.onerror和unhandledrejection的用法,到错误上报字段怎么设计、堆栈怎么清理,再到服务端上报通道怎么选、一个可以直接拷走的最小监控模块长什么样。文章不假设你有监控平台、不强迫你用Sentry,适合想把自研监控做扎实的前端同学,也适合全栈工程师自己搭一套轻量日志体系。
1. 为什么不能只靠 console.log(error)
1.1 console.log 只能证明“你看到了”,不能证明“线上发生了什么”
console.log有一个很迷惑人的特性:它真的能打印出Error对象,展开还能看到堆栈,看起来好像“错误已经被记录了”。但这里的记录只发生在当前浏览器当前页面。用户那边一旦关闭页面,这条日志就永久消失了。即便用户愿意配合,也不太可能自己打开DevTools把堆栈复制给你,大部分人连怎么打开控制台都不知道。
console.log另一个问题是没有上下文。你打出一个error,但它发生在哪个路由、哪个操作步骤、哪个版本、什么设备和系统上,这些信息全都没有。线上环境用户浏览器五花八门,有些Bug只在特定版本系统上触发,比如某个TypeError只在iOS旧版Safari出现,本地Chrome怎么复现都复现不出来。这时候如果连用户环境信息都没有,排查基本靠猜。
还有一个很容易忽略的问题:console.log不会通知任何人。它没有后台、没有聚合、没有告警。页面报错了,你既不知道,也查不到,只有用户自己默默忍受。所谓“前端可观测性”,第一步不是上多贵的监控平台,而是先把错误出口从控制台挪到有记录、有格式、能传输的地方。console.log适合开发调试,但绝不适合作为线上稳定性问题的记录介质。
1.2 第一步:先梳理要捕获哪些“异常”
很多同学一上来就写window.onerror,写完发现很多错误还是漏了。原因是前端异常种类比想象中多,全局error只能兜住一部分运行时错误,异步的Promise拒绝、资源加载失败、框架生命周期错误都需要不同的捕获入口。
| 异常类型 | 典型场景 | 捕获入口 |
|---|---|---|
| JS运行时错误 | TypeError、ReferenceError、SyntaxError | window error事件 |
| 未处理的Promise拒绝 | 接口异常但没处理catch、异步函数内部报错 | unhandledrejection事件 |
| 资源加载失败 | 图片、CSS、JS、字体加载失败 | error事件捕获阶段 |
| 接口请求失败 | HTTP状态码非2xx、超时、断网 | axios/fetch拦截器 |
| 框架生命周期异常 | 组件render、生命周期、事件回调抛错 | Vue errorHandler / React ErrorBoundary |
| 业务主动异常 | 登录失效、参数校验失败、权限不足 | 业务代码主动上报 |
我的建议是别在代码里到处写try/catch去手动捕获,那样业务代码会膨胀得很厉害。正确思路是全局捕获为主、局部主动上报为辅。全局捕获保证了“不遗漏”,框架钩子和业务埋点保证了“有上下文”。比如登录过期这种业务异常,全局监听根本不知道它有什么业务意义,只有业务代码自己主动调一次上报接口,把场景信息带上去,后面才能做针对性分析。
2. 异常捕获方案:全局监听与框架钩子
2.1 全局 error 事件:兜住未捕获的运行时错误
先说最核心的window.error监听。这里强烈建议用addEventListener,而不是直接给window.onerror赋值。因为onerror是单赋值模式,很容易被团队里其他人覆盖掉,addEventListener则支持多个处理器共存。
window.addEventListener('error', function (event) { // 先判断是不是资源加载错误:event.target有src/href属性 const target = event.target; if (target && (target.src || target.href)) { reportResourceError({ type: 'resource', message: '资源加载失败: ' + (target.src || target.href) }); return; } // 运行时错误:从event.error里拿真实Error对象 const err = event.error || {}; reportError({ type: 'js', message: err.message || event.message, stack: err.stack || '', file: event.filename, line: event.lineno, col: event.colno }); }, true);这里有两个重要细节。第一个是监听器第三个参数传true,让事件在捕获阶段触发。普通冒泡阶段拿不到资源加载错误,因为load/error这类事件不会像普通事件那样冒泡到window,只有捕获阶段才能统一拦住。第二个是资源错误和运行时错误要分开处理,资源加载失败时event.error通常是undefined,从target.src里才能拿到真实地址。
还有个经典问题:跨域脚本报错时,浏览器为了安全会把错误信息隐藏,只给你一个“Script error.”。解决办法是给script标签加crossorigin="anonymous",同时CDN服务端返回Access-Control-Allow-Origin响应头。加了这两样,才能拿到原始堆栈。自建监控时遇到大量Script error.,十有八九是这一步没做。
2.2 unhandledrejection:Promise 世界的漏网之鱼
JS运行时异常只占线上错误的一部分,现在业务越来越依赖异步,Promise链里的错误才是大头。async/await写起来舒服,但如果不小心漏了catch,整个Promise链断掉时只会抛出一个unhandledrejection事件。try/catch对异步rejection是无效的,你根本不可能在每个调用方那里都套一层。
window.addEventListener('unhandledrejection', function (event) { event.preventDefault(); const reason = event.reason; let error = reason; // rejection的值不一定是Error对象,可能是字符串、对象、undefined if (!(reason instanceof Error)) { try { error = new Error(typeof reason === 'string' ? reason : JSON.stringify(reason)); } catch (e) { error = new Error(String(reason)); } } reportError({ type: 'promise', message: error.message, stack: error.stack }); });event.preventDefault()的意义在于阻止浏览器在控制台输出默认的“Uncaught (in promise)”红色报错。有些团队不喜欢调这个方法,希望保留控制台报错方便调试。这个看各自偏好,但自研监控时我建议保留preventDefault,因为我们的reportError内部在开发环境也会console.error,不影响调试。
要注意的是,如果业务代码里有一条Promise调用链始终没有catch,但错误又不是每次复现,unhandledrejection就会成为你线上定位异步问题唯一的线索。所以这个监听器一定要在入口文件最早的位置绑定,越早越好,否则在监听器注册之前发生的rejection会静默丢失。
2.3 Vue 和 React:框架钩子要接上
全局监听能兜住大部分未捕获异常,但框架内部抛出的错误,很多时候需要框架自己的钩子才能拿到“组件上下文”。以Vue为例,组件render函数、生命周期函数、事件处理器里抛的错,虽然也会冒泡到window,但你已经不知道是哪个组件、哪个生命周期阶段出的问题了。
// Vue 3 const app = createApp(Root); app.config.errorHandler = (err, instance, info) => { reportError({ type: 'vue', message: err.message, stack: err.stack, componentName: instance ? (instance.$.__name || instance.$options.name) : 'unknown', lifecycle: info }); };React从16开始提供ErrorBoundary,它同时承担了UI降级和错误采集两个职责。我的经验是ErrorBoundary不要包在根组件最外层只套一次,而是在业务区域、sidebar、Content等大块区域各套一个,这样错误上报时能带上“是哪块区域挂了”的信息。
class ErrorBoundary extends React.Component { componentDidCatch(error, errorInfo) { reportError({ type: 'react', message: error.message, stack: error.stack, componentStack: errorInfo.componentStack }); } render() { return this.props.children; } }框架级捕获和全局捕获是互补关系,不是二选一。全局监听负责兜底所有漏网之鱼,框架钩子负责给错误塞入组件名、生命周期这些上下文。两条一起用,格式化的时候信息才够饱满。
3. 统一格式化:让错误从字符串变成结构化数据
3.1 上报字段设计:一个错误至少要携带这些信息
本地console.log打出的Error对象是给开发者看的,格式随意、字段不全,服务端根本没法做聚合。统一格式化的核心目标,是把任意来源的错误转换成同一套JSON结构,这样后端的Elasticsearch或者普通数据库才能按字段索引和统计。
以下是我实际项目里用的上报字段表,覆盖了“谁、什么时候、在哪个页面、什么环境、发生了什么”五个问题。
| 字段 | 类型 | 说明 |
|---|---|---|
| hash | string | 错误唯一标识,用于聚合去重 |
| type | string | js/promise/resource/http/vue/react/business |
| message | string | 错误消息,截断到200字符 |
| stack | string | 错误堆栈,截断到2000字符 |
| project | string | 项目名,多项目共用一套上报服务时区分来源 |
| version | string | 发布版本号,用于对比新旧版本错误率 |
| pageUrl | string | 当前页面URL |
| route | string | 前端路由,单页应用里比pageUrl更稳定 |
| userId | string | 脱敏后的用户标识 |
| userAgent | string | 完整UA,注意控制长度 |
| timestamp | number | 发生时间戳 |
这里要特别提醒脱敏问题。拿真实用户上报时,很多人图省事直接采集location.href,但URL里很可能带着query参数,包括token、手机号、邀请码这类敏感信息。自建监控没有任何理由把这些完整参数传到服务端。要么只存pathname加路由参数名,要么对query做白名单筛选。userId可以做维度分析,但登录token最多保留前四位用于标识,绝对不能存完整值。
3.2 堆栈清理与 SourceMap 还原
堆栈是错误定位最重要的信息,但浏览器返回的原始堆栈往往很脏,不同浏览器的格式也不一样。Chrome的堆栈长这样:Error: xxx at fn (file.js:10:20),Firefox和Safari则各有差异。统一格式化时首先要做标准化:去掉多余空行,把堆栈截断到合理长度,防止某些浏览器抛出几百行堆栈把请求体撑爆。
生产环境还有一个更大的问题:代码经过压缩混淆后,函数名变成单个字母,文件名变成一长串hash值,堆栈根本没法看。这时候需要SourceMap还原。自建监控要做的不是把map文件暴露到公网,而是在构建阶段把sourcemap上传到内部地图服务,前端只上报version和行列号,服务端根据版本号找到对应map文件,异步解析回原始源码位置。
注意:sourcemap千万别放在公网可访问的目录下,否则等于把源代码直接公开了。自研方案里map文件就应该只存在于内网或带鉴权的服务中,前端上报时只带version标识,由服务端去拉取对应map做还原。
3.3 去重、采样与上报优先级
没有去重逻辑的上报系统上线后,第一个崩溃的是后端接口。同一个错误在同一个用户浏览器里可能每隔几秒触发一次,比如轮询接口挂了,10分钟能刷出上百条完全相同的错误。需要给错误算一个hash,通常在message加上堆栈前几行组合生成,然后做时间窗去重。
我的默认策略是:同一个hash在同一用户同一页面的10分钟内只上报一次。这个窗口足够做聚合统计,又不会让后端被重复数据打爆。上报优先级上,白屏、脚本崩溃、首屏接口失败要保证必达,一些非关键警告甚至可以丢弃。采样率也不用写死,可以做成可配置的:默认10%采样,但当某个hash的错误次数超过阈值时自动对该hash全量采集,这样既能控流量,又能在问题爆发时拿到足够样本。
4. 服务端上报:从页面到日志的完整链路
4.1 上报通道:sendBeacon 与 fetch keepalive 怎么选
客户端采集到格式化后的错误,还需要一个可靠的传输通道。早期很多博客让你用new Image().src打点,因为图片请求天然支持跨域、不需要CORS配置、请求体小。但它的缺点也很明显:信息只能拼在URL里,长度受限,还得手动encodeURIComponent。现在项目里我更推荐用sendBeacon,其次是fetch keepalive。
function reportBatch(items) { const data = JSON.stringify({ project: getProject(), events: items }); if (navigator.sendBeacon) { try { const blob = new Blob([data], { type: 'application/json' }); navigator.sendBeacon(reportUrl, blob); return; } catch (e) { // 部分低版本浏览器对Blob支持不完整,降级到fetch } } fetch(reportUrl, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: data, keepalive: true }).catch(function () { // 上报失败不要让用户感知,这里尤其不能再次走正常上报流程 }); }sendBeacon最大的优势是页面卸载时请求依然会送达,非常适合在pagehide、visibilitychange这类事件里做“最后冲刺”。它的问题是无法自定义请求头,鉴权只能靠URL参数或者Cookie,所以也需要服务端容忍这种传参方式。fetch keepalive可以自定义Header,但payload大小建议控制在64KB以内,超出后浏览器可能会直接丢弃。
提示:sendBeacon对Content-Type要求比较严格,我踩过的坑是直接把JSON字符串传进去,部分浏览器会把它当text/plain。标准做法是包一层Blob并明确type为application/json。
4.2 批量上报与本地缓存
单条错误马上上报,高频错误场景下会产生大量请求,而且每一条都建立一个TCP连接,非常浪费。正确做法是在前端维护一个队列,攒够一定数量或者到达时间间隔后统一批量POST。批量还有额外好处:服务端可以按一条日志里的多条events做批量写入,吞吐量比单条插入大得多。
let queue = []; let timer = null; function push(payload) { queue.push(payload); if (!timer) { timer = setTimeout(function () { flush(); }, 2000); } } function flush() { clearTimeout(timer); timer = null; if (!queue.length) return; const events = queue.splice(0, 20); // 单次最多带20条 reportBatch(events); }如果上报的这20条因为网络故障没发出去,丢了可惜,所以可以配合localStorage做本地缓存。队列写入localStorage时建议设置上限,比如最多存100条,超过上限时丢弃最老的。localStorage容量只有5MB左右,写入和读取都要try/catch,否则碰到隐私模式下容量溢出会直接抛异常。
4.3 服务端接收与前后端 requestId 串联
服务端接口设计不复杂,一个POST接口接收JSON,校验字段后落到日志系统或者数据库即可。但光接收还不够,前端上报的错误很多时候是接口异常引起的,需要和服务端日志串起来看。我的做法是入口处生成一个requestId,前端所有请求都携带这个ID,后端中间件统一把它记录到日志里。前端上报的错误payload里也带上同一个requestId,这样排查链路时,从浏览器错误能一路捞到服务端具体那一条日志。
前后端异常处理要互相兜底。前端上报解决了“用户浏览器里发生了什么”,但接口500这种问题如果前端没捕获到,也不能只依赖前端。后端中间件兜底仍然很重要,以Python Django为例,可以在中间件里统一记录未处理异常,之后再抛给框架返回500。
# Django中间件示例:兜底记录未捕获异常 class ExceptionLogMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): try: response = self.get_response(request) except Exception as e: logger.exception("unhandled exception: %s", e, extra={ "path": request.path, "request_id": request.headers.get("X-Request-Id", ""), }) raise return response注意中间件记录后要重新抛出,不要自己吞掉异常,否则所有接口都会返回200,前端会误以为逻辑成功。前端和后端各出现一串对应字段的日志,靠requestId关联上,排查效率会高很多。
5. 可直接复制的前端异常上报模块(monitor.js)
5.1 核心实现:从捕获到格式化再到上传
这一节给出一个自用版的monitor.js,不依赖任何第三方库,直接放在项目入口引入就能用。代码做了三件事:注册全局监听、统一格式化、批量上报。注释里我标了几个容易踩坑的位置。
(function () { const config = { project: 'default', version: '1.0.0', reportUrl: '/api/log/errors', sampleRate: 1, // 1表示全量上报,0.1表示采样10% dedupeWindow: 10 * 60 * 1000, // 同一hash去重窗口10分钟 maxQueueSize: 20, // 单次批量最多条数 flushInterval: 2000 // 批量上报时间间隔 }; let queue = []; let timer = null; let seen = {}; const pageInfo = {}; function hashCode(str) { let h = 0; if (str.length === 0) return 0; for (let i = 0; i < str.length; i++) { h = ((h << 5) - h) + str.charCodeAt(i); h |= 0; } return String(h); } function normalizeError(e) { if (e instanceof Error) { return { message: e.message || String(e), stack: e.stack || '' }; } if (typeof e === 'string') { return { message: e, stack: '' }; } try { return { message: '非Error对象异常', stack: JSON.stringify(e) }; } catch (ex) { return { message: '非Error对象异常,序列化失败', stack: String(e) }; } } function buildPayload(type, e) { const normalized = normalizeError(e); const hashSource = (normalized.message || '') + '|' + (normalized.stack || '').split('\n').slice(0, 2).join('\n'); return { hash: hashCode(hashSource), type: type, message: String(normalized.message).slice(0, 200), stack: String(normalized.stack).slice(0, 2000), url: location.href, route: pageInfo.route || '', project: config.project, version: config.version, userAgent: navigator.userAgent, timestamp: Date.now() }; } function push(payload) { if (config.sampleRate < 1 && Math.random() > config.sampleRate) return; const now = Date.now(); const last = seen[payload.hash] || 0; if (now - last < config.dedupeWindow) return; seen[payload.hash] = now; queue.push(payload); if (!timer) { timer = setTimeout(flush, config.flushInterval); } } function flush() { clearTimeout(timer); timer = null; if (!queue.length) return; const events = queue.splice(0, config.maxQueueSize); const body = JSON.stringify({ project: config.project, events: events }); if (navigator.sendBeacon) { try { const blob = new Blob([body], { type: 'application/json' }); navigator.sendBeacon(config.reportUrl, blob); return; } catch (e) { // 降级到fetch } } try { fetch(config.reportUrl, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: body, keepalive: true }).catch(function () {}); } catch (e) { // 上报模块自身异常,不再递归上报 } } function handleGlobalError(event) { const target = event.target; if (target && (target.src || target.href)) { push(buildPayload('resource', new Error('资源加载失败: ' + (target.src || target.href)))); return; } const err = event.error; if (err) { push(buildPayload('js', err)); } else { push(buildPayload('js', new Error(event.message + ' @ ' + event.filename + ':' + event.lineno + ':' + event.colno))); } } function handleRejection(event) { event.preventDefault(); push(buildPayload('promise', event.reason)); } function init(options) { Object.assign(config, options || {}); pageInfo.route = location.hash || location.pathname; window.addEventListener('error', handleGlobalError, true); window.addEventListener('unhandledrejection', handleRejection); window.addEventListener('pagehide', flush); } function captureError(type, e, extra) { const payload = buildPayload(type, e); if (extra) payload.extra = extra; push(payload); } window.Monitor = { init: init, captureError: captureError }; })();这个模块写得很克制,但刚好覆盖了前面讲的核心点。如果项目里有Vue,入口文件再补一行app.config.errorHandler,调Monitor.captureError('vue', err)就行。React则是在ErrorBoundary的componentDidCatch里调用captureError。
5.2 接入项目的三种姿势
第一种是最简单的入口模式,在main.js或应用入口顶部初始化。上报接口路径、项目名、版本号都可以通过参数传进去。我建议开发环境直接禁用上报,或者把上报行为降级为console.error,避免自己调试时把后台日志刷爆。
import './monitor'; Monitor.init({ project: 'shop-h5', version: '2.4.0', reportUrl: 'https://log.example.com/api/log/errors', sampleRate: 1 });第二种是给框架设定全局钩子,Vue放到createApp之后统一调用errorHandler,React通过ErrorBoundary包裹顶层组件。这两种方式适合中后台项目,组件树深、业务量大,靠手动一个个catch不现实。
第三种是给业务代码留一个手动上报入口。比如axios的响应拦截器里对HTTP状态码5xx统一做一次上报,或者登录态失效时主动调用Monitor.captureError('business', new Error('登录过期'))。这样做的价值在于把业务语义一起带进监控系统,后续可以按业务模块统计错误。
另外一点建议:监控代码可以抽象成公共组件库里的独立模块。公司多个项目都用到监控时,与其每个项目复制一份monitor.js,不如打成npm包或者放进统一维护的公共组件库,把上报地址、版本号、运行环境这些做成全局配置。这样后续要加采样、加sourcemap还原,一处改动所有项目同步生效。
6. 常见问题、排查技巧与最后的个人建议
6.1 错误上报“失灵”问题速查表
自己搭建监控体系时,最烦的不是没捕获到错误,而是明明捕获到了、上报也发出了,后端却查不到。下面是我整理的问题速查表,基本都是实际见过的情况。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 跨域脚本报错只显示Script error. | script标签缺crossorigin或CDN缺CORS头 | 加crossorigin="anonymous",服务端配Access-Control-Allow-Origin |
| Promise错误一个都没上报 | 监听器绑定太晚,或事件被框架内部消费 | 入口文件最先执行,使用捕获阶段监听 |
| 上报请求已经发出,服务端没收到 | Content-Type不对,或sendBeacon传了纯字符串 | 用Blob包一层并设置application/json |
| 同一条错误刷了几百条 | 轮询接口挂了或重试机制触发 | 按hash做时间窗去重,窗口可配置 |
| 生产堆栈全是压缩代码 | 没做sourcemap还原 | 构建后上传map到内网,按版本号还原 |
| 老版本浏览器不上报 | 不支持Promise/sendBeacon/fetch | 引入polyfill或降级到Image打点 |
排查这类问题最快的方式是打开浏览器Network面板,直接看上报接口有没有请求、状态码是不是200、响应体有没有被网关拦截。如果请求正常但服务端没入库,再检查后端日志里是否收到了JSON,很多情况下是字段名对不上或者请求体太大被Nginx默认配置截断。
6.2 我实际趟过的一些坑
第一个坑是开发环境误开上报。有一阵子我调试本地模块时,刷新页面就出一堆错误日志,后来发现是dev环境的错误也会被批量上报到测试库,把数据搞得很脏。现在我在init里加了环境判断,开发环境只console.error不上报,只有NODE_ENV为production时才真正发起网络请求。
第二个坑是上报模块自身的异常导致死循环。比如fetch的catch里再次调了上报方法,一旦上报接口域名解析失败,就会陷入“上报失败-上报-再失败”的循环。解决办法是上报模块内部所有异常全部原地消化,任何情况下都不递归触发第二次上报。这个原则必须写死在代码规范里。
第三个坑是页面卸载时的丢失。很长一段时间我忽略了pagehide时的批量上报,导致用户快速关闭页面时错误丢失率非常高。后来在pagehide里调了一次flush,配合sendBeacon把队列里剩余的错误全部发送出去,丢失率才明显下降。如果用的是SPA,还要额外监听路由变化,因为history模式下的跳转不会触发pagehide。
6.3 关于白屏和来路的最后建议
自建上报做了半年后,我最大的体会是:单条错误的价值远低于聚合后的趋势。与其一条条点开看报错详情,不如先看“这个版本相比上个版本错误率涨了多少”“哪个路由报错最多”“影响到的用户数是多少”。同一用户在一个小时里刷出100条相同错误,危害远小于100个用户各遇到一次错误,所以我的监控视图都是按“影响人数”排序,而不是按错误次数。
最后再分享一个对白屏问题特别有效的技巧。全局error里一旦捕获到致命错误,顺手把document.documentElement.outerHTML或者关键容器的DOM快照一起上报,有条件的话用html2canvas截一张图传上去。很多时候崩溃类Bug无法从堆栈直接判断原因,但一张当时的页面截图能立刻告诉你是不是布局错乱、资源缺失或者样式被覆盖。这一招在白屏排查里救过我很多次,强烈建议试试。