
排查线上问题的时候最怕听到的不是“出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、SyntaxErrorwindow 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标签加crossoriginanonymous同时CDN服务端返回Access-Control-Allow-Origin响应头。加了这两样才能拿到原始堆栈。自建监控时遇到大量Script error.十有八九是这一步没做。2.2 unhandledrejectionPromise 世界的漏网之鱼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或者普通数据库才能按字段索引和统计。以下是我实际项目里用的上报字段表覆盖了“谁、什么时候、在哪个页面、什么环境、发生了什么”五个问题。字段类型说明hashstring错误唯一标识用于聚合去重typestringjs/promise/resource/http/vue/react/businessmessagestring错误消息截断到200字符stackstring错误堆栈截断到2000字符projectstring项目名多项目共用一套上报服务时区分来源versionstring发布版本号用于对比新旧版本错误率pageUrlstring当前页面URLroutestring前端路由单页应用里比pageUrl更稳定userIdstring脱敏后的用户标识userAgentstring完整UA注意控制长度timestampnumber发生时间戳这里要特别提醒脱敏问题。拿真实用户上报时很多人图省事直接采集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.js5.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之后统一调用errorHandlerReact通过ErrorBoundary包裹顶层组件。这两种方式适合中后台项目组件树深、业务量大靠手动一个个catch不现实。第三种是给业务代码留一个手动上报入口。比如axios的响应拦截器里对HTTP状态码5xx统一做一次上报或者登录态失效时主动调用Monitor.captureError(business, new Error(登录过期))。这样做的价值在于把业务语义一起带进监控系统后续可以按业务模块统计错误。另外一点建议监控代码可以抽象成公共组件库里的独立模块。公司多个项目都用到监控时与其每个项目复制一份monitor.js不如打成npm包或者放进统一维护的公共组件库把上报地址、版本号、运行环境这些做成全局配置。这样后续要加采样、加sourcemap还原一处改动所有项目同步生效。6. 常见问题、排查技巧与最后的个人建议6.1 错误上报“失灵”问题速查表自己搭建监控体系时最烦的不是没捕获到错误而是明明捕获到了、上报也发出了后端却查不到。下面是我整理的问题速查表基本都是实际见过的情况。现象可能原因处理办法跨域脚本报错只显示Script error.script标签缺crossorigin或CDN缺CORS头加crossoriginanonymous服务端配Access-Control-Allow-OriginPromise错误一个都没上报监听器绑定太晚或事件被框架内部消费入口文件最先执行使用捕获阶段监听上报请求已经发出服务端没收到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无法从堆栈直接判断原因但一张当时的页面截图能立刻告诉你是不是布局错乱、资源缺失或者样式被覆盖。这一招在白屏排查里救过我很多次强烈建议试试。