☰
从console.log到服务端上报:前端异常捕获与监控体系实战
2026/10/4 11:29:40 网站建设 项目流程

前两天线上出了一个挺诡异的事故:用户反馈某一批订单创建不出来,报错的提示语却是个白屏。我拿到反馈第一反应是打开控制台,果不其然——console.log(error)把堆栈打印得清清楚楚,但用户那边什么都没有。这个场景是不是很熟悉?前端异常一直在发生,但真正被我们看见的,可能只有开发电脑控制台里的那几行红字。

做了几年前端,越来越觉得"异常捕获"这件事不是大厂才需要的基础设施。没有上报体系的时候,排查线上问题基本靠三件事:用户愿意截图、客服能问到关键信息、或者自己赌运气复现。这个流程太脆弱了。这篇文章我就把自己从console.log(error)到服务端上报的完整落地过程拆开讲讲,包括捕获哪些异常、如何统一格式化、上报通道怎么选、以及后期处理 SourceMap 还原的一些经验,希望能给正在折腾前端监控的同学一个可参考的路径。

1. 先搞清楚:前端到底要捕获哪些异常

很多项目一做异常捕获,就把window.onerror一挂就完事了。但这个做法,说实话只能捕获"运行时脚本错误"这一类,覆盖面其实差得很远。前端异常用不同的分类方式可以切成好几块,每一块的捕获手段都不一样,必须先想清楚边界在哪里再动手。

1.1 运行时异常:window.onerror 的用武之地

运行时异常指的就是 JavaScript 代码执行到一半突然throw new Error()这种情况,比如:

const user = getUser() // undefined user.name // TypeError: Cannot read property 'name' of undefined

这一类异常在浏览器里会触发window.onerror,这也是大家最熟悉的老牌捕获方式。除了onerror,现代浏览器还支持window.addEventListener('error', ...)这种标准化写法,而且更推荐用后者,因为它不会被某些框架覆盖。

这里有个容易踩的细节:window.onerror能拿到五个参数(message, source, lineno, colno, error),而addEventListener版本只能从event.error里拿到错误对象。如果只用addEventListener,不带true去 capture 阶段捕获,资源加载错误(比如图片 404)是监听不到的,这个我后面专门说。

1.2 Promise 异步异常:最容易漏网的那批

如果说运行时异常是明面上的敌人,Promise 异常就是暗处的潜行者。看这段代码:

async function getData() { throw new Error('接口返回结构异常') } getData() // 没有 await,也没有 catch

这段代码执行到getData()的时候,异常不会进入window.onerror,而是会命中unhandledrejection事件。很多前端项目对异步的处理是"反正有 try/catch 或者 axios 拦截器兜底",但真到线上,漏掉的reject比想象中多得多。

捕获方式:

window.addEventListener('unhandledrejection', (event) => { const reason = event.reason // 注意:event.preventDefault() 可以阻止浏览器在控制台打印默认错误 reportError({ type: 'unhandledrejection', message: reason?.message || String(reason), stack: reason?.stack || '' }) })

实测下来,unhandledrejection的reason不一定是个Error对象,可能是一个普通字符串、一个对象、甚至一个null。格式化的时候必须做容错,不能默认它有stack和message,否则上报系统自己先炸了。

1.3 资源加载错误:另一片被忽略的战场

脚本文件 404、图片加载失败、CSS 文件被 CDN 边缘节点干掉——这些错误不会产生Error对象,也不会触发unhandledrejection。它们走的是window.addEventListener('error', handler, true)的捕获阶段,因为资源错误不会冒泡。

判断是不是资源错误,有个很实用的办法:

window.addEventListener('error', (event) => { // 判断事件目标是不是元素节点 const target = event.target const isElementTarget = target && (target.src || target.href) if (isElementTarget) { // 这就是资源加载错误 reportError({ type: 'resourceError', message: `资源加载失败: ${target.tagName}`, source: target.src || target.href, // 注意:资源错误没有 error.stack }) return } // 走到这里是运行时脚本错误 reportError({ type: 'runtimeError', message: event.message || '', stack: event.error?.stack || '', lineno: event.lineno, colno: event.colno, }) })

从这个判断逻辑也能看出来,addEventListener的true参数为什么必须加上。默认冒泡阶段,内部脚本错误和资源错误都会被别处的捕获逻辑吃掉,监听器根本收不到。

1.4 框架层错误:Vue 和 React 各自的门道

用 Vue 或者 React 这类框架,光靠原生捕获还不够。框架内部的错误处理有自己的机制,不接管的话,很多错误会经过框架的日志系统打出来,但不会进入window.onerror。

Vue 2 里用:

Vue.config.errorHandler = (err, vm, info) => { reportError({ type: 'vueError', message: err.message, stack: err.stack, info, // Vue 自家提供的错误上下文,比如生命周期钩子名 }) }

Vue 3 的生命周期钩子名换了,errorHandler的第二个参数变成了instance,用法类似。React 这边更简单也更严格——componentDidCatch或者getDerivedStateFromError必须在错误边界组件里用,没有全局的React.onError这种设置。所以 React 项目通常是在顶层组件包一层ErrorBoundary,然后在componentDidCatch里做上报。

还有一个小点容易被忽略:框架的错误处理器接管之后,window.onerror不一定还会触发。比如 Vue 的errorHandler会把错误吞到自己的逻辑里,如果你同时全局监听了onerror,可能收到的是框架包装后的信息,原堆栈反而丢了。所以在设计上报的时候,要么以框架捕获为主、原生兜底为辅,要么做一层去重,避免一条错误上报两次。

2. 统一格式化:设计一套谁都能看懂的异常数据结构

捕获只是一半,另一半是把异常变成一个"结构化、可检索、可排序"的数据对象。没有统一格式的时候,错误日志长什么样全靠心情:有人上报message字符串,有人把整个error对象扔进 JSON,还有人直接把console.log(error)的内容贴到 issue 里。这直接导致后端的检索逻辑写不下去,因为同一个错误来自不同页面,字段都对不上。

2.1 一个贴近实战的异常数据模型

我最终沉淀下来的上报结构是这样,前端 SDK 和后端接口同时使用:

{ // 基础信息 "project": "mall-console", // 项目标识,多项目共用一套上报系统时必填 "version": "1.4.2", // 应用版本号,配合 SourceMap 使用 "env": "production", // 环境:development / staging / production "page": "/order/create", // 当前路由,SPA 里注意用路由库拿,不要用 location.href // 异常核心字段 "type": "runtimeError", // runtimeError | unhandledrejection | resourceError | vueError | reactError "message": "Cannot read property 'name' of undefined", "stack": "at VueComponent.getUser (app.js:1:2345)...", // 原始堆栈 "stackFrames": [], // stack 解析后的结构化帧数组(可选,前端解析或后端解析均可) // 运行时环境 "userAgent": "Mozilla/5.0 ...", // 需要自己精简,完整 UA 太长 "browser": { "name": "Chrome", "version": "112.0.0" }, "os": { "name": "Windows", "version": "10" }, "deviceType": "desktop", // desktop | mobile | tablet // 上下文信息 "extra": { "apiUrl": "/api/order/create", "requestId": "xx-xxx", "userId": "123456" }, // 时间 "timestamp": 1711000000000 }

有些字段不是必须的,但有一点要说清楚:多余的字段在排查的时候全是线索。比如page字段,SPA 路由切换之后,location.href往往拿不到准确的业务页面,我习惯在路由守卫里给当前页面名挂到全局变量上,上报时直接读。

2.2 Error 对象的序列化陷阱

前端上报前大都会做一层 JSON 序列化,这里有个坑:JSON.stringify 一个 Error 对象,拿到的只有{}。

const err = new Error('出错了') JSON.stringify(err) // '{}'

因为Error的message、stack这些属性都是不可枚举的,JSON.stringify默认只遍历可枚举属性。直接序列化的话,后端收到的就是一个空对象,排查个寂寞。

解决方式很简单,做一次显式提取:

function normalizeError(err) { if (err instanceof Error) { return { message: err.message, stack: err.stack, name: err.name, fileName: err.fileName, lineNumber: err.lineNumber, columnNumber: err.columnNumber, } } // 有些 rejected 的值根本不是 Error if (typeof err === 'object') { return { message: JSON.stringify(err), stack: '' } } return { message: String(err), stack: '' } }

这里还有个细节:JSON.stringify(err)如果 err 里有循环引用(比如某个业务对象直接挂在 Error 上),会直接抛出TypeError: Converting circular structure to JSON,所以normalizeError里的JSON.stringify最好包一层 try/catch,否则上报自身也会变成一项新异常。

2.3 堆栈信息太长?截断和帧结构化解码

完整堆栈可能几千字符,塞进 GET 请求的 URL 里直接炸掉长度限制,塞进日志系统也会占满单条索引。我在生产环境里做了两个处理:

第一,对堆栈字符串做长度截断,单条异常消息体和堆栈总和控制在 8KB 以内,超出部分丢掉。这不是为了省存储,是因为多数日志系统单条索引有大小上限,超过了要么被拒绝写入,要么被截断成乱码。

第二,通过 stacktracejs 这类库把堆栈解析成结构化数组。解析后的帧长这样:

{ "fn": "VueComponent.getUser", "file": "app.js", "line": 123, "col": 45 }

结构化帧的优势在于:后端可以按fn字段做聚合,搜"哪个函数出错次数最多",比搜全文要快得多。更关键的是,行号和列号字段在 SourceMap 还原时可以直接复用,不需要再写一套解析逻辑。

3. 上报通道怎么选:从 img 天眼到 Beacon

格式定好了,接下来考虑怎么把数据送到服务端。这块的选择直接影响成功率上限,我拿几种主流方案实测对比过。

3.1 四种上报方式横向对比

上报方式优点缺点适用场景
new Image().src = url不受 CORS 限制(GET 请求)、代码简单、老浏览器兼容URL 长度限制、只能 GET、不能自定义请求头轻量级上报、跨域场景
fetch('/api/report', { method: 'POST' })容量大、可以带请求头、可做批量页面卸载时可能中断、受 CORS 限制常规上报、批量上报
navigator.sendBeacon(url, data)页面卸载时也能可靠发送、POST、体积上限 64KB不支持自定义请求头、部分老浏览器不支持页面关闭/路由切换时上报
基于XMLHttpRequest兼容性最好、能带请求头、支持超时控制代码最重、同步模式下会阻塞页面需要兼容超老浏览器

正常页面运行中对上报的可靠性要求没那么高,fetch就够了;但用户刷新页面、关闭标签页这种场景,常规请求会被立刻 abort,唯独sendBeacon能保证把数据送出去。页面卸载时报的那条错,往往恰恰是最值得收的。

3.2 批量上报与限流:别把后端打挂了

异常多了以后,每条异常都发一个 HTTP 请求,对后端是种压力,对前端也是种性能损耗。这里我做了两层控制:

class Reporter { constructor({ apiUrl, batchSize = 8, interval = 5000 }) { this.apiUrl = apiUrl this.batchSize = batchSize this.interval = interval this.queue = [] this.timer = null this.lastFlushTime = 0 } push(data) { this.queue.push(data) // 达到批量上限立即发送 if (this.queue.length >= this.batchSize) { this.flush() return } // 防抖:距离上次发送超过 interval 才发送 if (Date.now() - this.lastFlushTime >= this.interval) { this.flush() } } async flush() { if (this.queue.length === 0) return const list = this.queue.splice(0, this.batchSize) this.lastFlushTime = Date.now() try { await fetch(this.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ list }), keepalive: true, // 页面跳转时尽量保活 }) } catch(e) { // 上报失败不阻塞业务,打印一条日志即可 console.warn('[monitor] report failed', e) } } }

这个实现里有几个点值得说一下:

  • keepalive: true可以让 fetch 请求在页面卸载时尽可能发完,效果和 sendBeacon 类似,但注意有大小限制(keepalive 请求总和约 64KB)。
  • 批量接口设计成{ list: [...] },一条请求带上 8 条异常,后端按数组解包逐条落库。
  • 限流还有一层含义:同一个错误在短时间内重复出现,比如某接口 5 秒内被调了 20 次错了 20 次,就没必要上报 20 条。我在 SDK 里增加了简单去重:以type + message + 单页运行时的 hash为 key,短时间内只发一条,同时计数 20 次写进extra.occurrence字段。这样后端统计"错误影响用户数"和"影响 PV"时能对上真实数据。

3.3 上报失败与离线缓存:日志不能丢在用户终端

移动端网络环境差是很现实的问题,上报请求本身可能失败。两个缓解方案:

一是 localStorage 暂存。发送失败时把数据写进 localStorage,下次成功前先合并旧数据再发。容量一般控制在 2MB 内,超出就丢弃最早的日志。

二是上报握手重试。发送失败时做指数退避重试,第一次等 2 秒,第二次 4 秒,最多三次。超过三次不再挣扎,避免反复请求消耗用户流量。

这两个方案听起来麻烦,但在实际运营里作用很明显。有一次一个低版本 App 内嵌的 H5 页面连续报错,用户处于弱网环境,靠 localStorage 缓存,拿到数据后发现原来是 App 的 WebView 缓存机制把某个 JS 版本缓存成了旧版,数据一对比就定位了问题根因。

4. 实战排查:从一条格式化日志到揪出真正的 Bug

上面说的是基建,这一节放一个真实案例,展示一下这套体系上线之后怎么改变排查链路。这个案例是"接口返回异常但前端不报错"的经典类型。

4.1 案件现场:白屏但没有 JS Error

某个订单列表页在部分用户手机上白屏,但我们的异常监控里一条runtimeError都没有,只有零星几条resourceError。如果放到以前,我大概率会让用户清缓存、换网络再试,而现在我先看上报数据。

注意到一个细节:白屏用户上报的日志里,page字段是/order/list,env是 production,但extra.apiData里记录的接口返回结构与其他正常用户不一样——多了一个promotionList字段,而且这个字段是字符串,不是预期中的数组。

4.2 为什么监控没抓到?问题藏在异步边界

回到代码,页面初始化逻辑是这样:

this.loading = true const res = await getOrderList() this.orderList = res.data.list this.promotions = res.data.promotionList.map(...) // 这里假设是数组

promotionList是字符串时,map方法不存在,应当抛TypeError。但为什么监控没抓到?

排查后发现,框架内部或者业务代码中某处有兜底的catch:

try { await init() } catch (e) { // 静默吞掉,只打了 console.log(e) }

这正是很多项目的通病——catch块只做console.log(error),然后什么都补做。错误被捕获并吞掉了,window.onerror永远触发不了。这是"从 console.log(error) 到服务端上报"最关键的差别:console.log 只让开发者看见,上报让整条链路都能看见。

后来我们把所有静默 catch 位置都梳理了一遍,规范改成:业务 catch 里至少调用一次reportError({ type: 'caughtError', ... }),把吞掉的异常也纳入监控。

4.3 修复与验证:同样的日志,排查效率天壤之别

修复方案其实简单:接口层做数据校验,promotionList不是数组时兜底换成[]。但更有价值的是,这次过程暴露了体系薄弱点:异常上报不只覆盖"未捕获异常",还要覆盖"被吞掉的异常"。

上线后我做了个回查,针对同一个接口调整,监控里立刻能看到不同版本用户的错误分布变化。老版本有 200 条错误,新版本 20 条,数据对新老版本差异一目了然。没有这套上报体系,全凭用户反馈猜,很难有这样的效率。

5. SourceMap 还原:让堆栈从「天书」变成「可读代码」

格式化上报之后,堆栈里出现的通常只有压缩文件路径和混淆后的变量名,比如JS_0.js:1:23456。到这一步排查还是费劲。正确的解法是接入 SourceMap。

5.1 生产环境要不要暴露 SourceMap,先解决这个顾虑

很多团队不敢把 SourceMap 传到公网,担心暴露源码。这个担心合理,做法上有一个折中方案:SourceMap 仅上传到内部监控平台,不部署到 CDN 或静态服务器。这样线上用户永远拿不到 map 文件,只有管理员在监控后台查询时,后端才返回对应的还原片段。

常见做法是构建完之后把 map 文件上传监控平台,然后从静态资源目录里删掉:

# 构建脚本示意 npm run build node scripts/upload-sourcemap.js --dir dist/js --endpoint https://monitor.internal.com/sourcemap rm -rf dist/js/*.map

5.2 还原过程:拿到行列号之后做了什么

前端上报时带上了version字段,后端拿到stackFrames里的文件名和行列号,在 SourceMap 存储中找到对应版本的 map 文件,用mozilla/source-map库做反查:

const { SourceMapConsumer } = require('source-map') async function resolvePosition(mapFile, line, col) { const consumer = await new SourceMapConsumer(mapFile) const pos = consumer.originalPositionFor({ line, column: col }) return { source: pos.source, line: pos.line, column: pos.column, name: pos.name } }

还原后的堆栈长这样:

at getOrderList (src/api/order.ts:45:23) at initPage (src/views/OrderList.vue:132:18)

从"压缩 JS 第 1 行第 23456 列"变成"某个源文件的某一行某一列",排查效率直接翻倍。这一步做完,前端监控才真正进入了"能看到源码位置"的层次。

5.3 版本对不上的坑:SourceMap 错位问题

SourceMap 还要求前端应用版本与远端存储的 map 文件版本严格对应。如果前端上线 1.4.2,SourceMap 系统里没传,全是 1.4.1 的,那还原出来的行列号一定是错的,甚至指向一个完全无关的代码片段。

我在上传脚本里加了版本号参数,构建流水线把package.json的版本和 git commit hash 一并发给监控平台:

node scripts/upload-sourcemap.js --version 1.4.2 --commit $(git rev-parse HEAD) --dir dist/js

在监控后台做异常列表时,version和commit可以印在一起,排查时一眼就能确认是不是当前线上版本。这属于基建工程里"做了不觉得、不做就出大问题"的典型项。

6. 继续扩展的方向:从「异常日志」到「可量化稳定性」

链路跑通之后,异常数据会越攒越多,下一步自然而然的动作是做统计与聚合。有几个方向我踩过之后觉得很值得投入:

第一,错误聚合分析。后端按type + message + stackFrames[0].fn做聚合,产出"接口错误 Top10""页面崩溃率 Top10"之类的报表。这个能力刚开始可能只是运维参考,后面会成为迭代排期的重要依据——哪些模块错误率高,就得优先重构。

第二,用户行为轨迹。异常上报时附加最近几次关键动作(点击按钮、路由切换、API 调用),比如:

// 在 SDK 内部维护一个全局轨迹数组 const breadcrumbs = [] function addBreadcrumb(action, data) { breadcrumbs.push({ action, data, time: Date.now() }) if (breadcrumbs.length > 10) breadcrumbs.shift() // 只保留最近10条 }

这个信息会在后端排查白屏问题时起到奇效。比如看到用户是在点击"确认支付"后白屏,而当时平板上超过 10 条resourceError,就能把问题缩小到某个静态资源挂了。

第三,告警。错误聚合超过阈值时推送通知到企业微信或者钉钉。阈值建议先粗后细:比如"某页面错误数量相比过去 7 天均值增长超过 3 倍"再告警,避免低级别噪声消耗响应精力。

前端异常捕获这条路,从console.log(error)到服务端上报,中间补的不仅仅是几行代码,更是把「发现问题-定位问题-评估影响」这条链路建立起来的过程。我自己的体会是:这套体系不一定要做得很大,但至少要做到"线上出了错,我不是最后一个知道的人"。如果你正在项目里推进这件事,先从window.onerror和unhandledrejection两条最基础的捕获接上,配上统一的格式化数据结构,再选一个靠得住的上报通道,这个闭环一旦跑起来,后面所有优化都有数据支撑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询