CClara轻量论坛系统

Vue 3 错误捕获与异常处理机制详解

beihuan
beihuan 见习用户见习用户
发布于 2026-10-29 02:58 ·2 浏览 ·11 回复
内容摘要

Vue 3 的错误最终都会经 handleError 分流到组件的 errorCaptured 钩子、全局 app.config.errorHandler 和控制台,可按渲染、setup、生命周期、事件处理器同步错误及 async 事件处理器返回的 Promise rejection 被捕获、而 setTimeout、独立 Promise、fetch().then() 和第三方库异步回调错误无法捕…

学完这篇,你能把 Vue 3 里散落在各处的报错统一收拢到一个出口——该拦的拦、该上报的上报,而不是让浏览器控制台一片红、用户白屏了你还不知道。

第一步:先认清 Vue 3 的错误有哪几条路

Vue 3 内部所有能被它感知的错误,最终都会走 handleError,分流到三个地方:组件的 errorCaptured 钩子(从出错组件开始逐层往上冒)、全局的 app.config.errorHandler、最后才是控制台的 logError。

所以你要处理的错误其实分三类:

  • Vue 管得到的:渲染函数出错、setup 里同步抛错、生命周期钩子抛错、事件处理器的同步错误。
  • Vue 半管得到的:事件处理器是 async 函数时,返回的 Promise 被 reject,Vue 会挂一个 catch 转给 handleError,能捕获到。
  • Vue 管不到的:setTimeout、独立的 new Promise()、fetch().then() 里自己没包 try/catch 的错误,以及第三方库内部异步回调的报错。

第二步:配置全局 errorHandler 兜底

在 main.js 里创建应用后立刻挂上:

const app = createApp(App)

app.config.errorHandler = (err, instance, info) => {
  console.error('[global]', info, err)
  // 上报:err.message / err.stack / info / instance?.$options.name
}

app.mount('#app')

三个参数分别是错误对象、出错的组件实例(可能为 null)、错误来源标识(形如 "render function"、"setup function"、"lifecycle hook (mounted)"、"v-on handler"、"component event handler")。info 是做错误分类统计最有价值的字段,别丢。

注意:千万不要在 errorHandler 里再抛出错误。它自身报错会再次触发 handleError,直接进入无限递归,页面卡死。上报逻辑一律包 try/catch,或者扔进队列异步上报。

第三步:用 onErrorCaptured 做局部拦截

有些模块你不想让错误直接冒到全局——比如一个侧边栏挂了不该让整个页面崩。在父组件里:

import { onErrorCaptured, ref } from 'vue'

const failed = ref(false)

onErrorCaptured((err, instance, info) => {
  console.warn('局部捕获', info, err)
  failed.value = true
  return false // 关键:阻止继续向上传播
})

配合 <template v-if="!failed"> 换成降级 UI,就是一个最小的错误边界。选项式 API 里对应 errorCaptured(err, instance, info) 钩子,返回值规则一样。

返回值的规则记牢:

  • 返回 false —— 阻止错误继续向上冒泡,全局 errorHandler 不再收到。
  • 返回其他任何值(含 undefined)—— 正常向上传播。
  • 钩子自己抛错 —— 这个新错误会继续往上冒,原本的错误反而被吞掉。

注意:errorCaptured 捕获不到子组件里 setTimeout、异步 Promise 链中的错误,它只处理 Vue 调用栈内的同步错误。别指望它当万能兜底。

第四步:用 window 事件补最后一层网

异步和框架外的错误只能靠浏览器全局事件:

window.addEventListener('error', (e) => {
  // 资源加载失败也能到这里,注意区分
  if (e.target && e.target !== window) return
  report(e.error || { message: e.message })
})

window.addEventListener('unhandledrejection', (e) => {
  report(e.reason)
})

注意 error 事件在资源(<img>、<script>)加载失败时也会触发,且 e.target 不是 window,用上面那行判断过滤掉,否则一堆 404 图片会污染你的错误报表。

另外这两个监听要在 app.mount() 之前注册,否则首屏渲染期间的错误会漏掉。

第五步:上报前做去重和限流

同一处渲染错误在一秒内可能触发几十次(尤其在循环里)。上报前至少做两件事:

  1. 按 err.message + info 生成指纹,同一指纹 5 秒内只报一次。
  2. 单页面上报总数封顶(比如 10 条),超了只记一条「错误过多已截断」。

报文体建议带:错误指纹、err.stack、info、当前路由 fullPath、用户标识、UA。没有路由和 info,线上排查基本等于盲猜。

第六步:开发期打开 warnHandler

if (import.meta.env.DEV) {
  app.config.warnHandler = (msg, instance, trace) => {
    console.warn(msg + trace)
  }
}

生产环境这个配置不生效,放心写。它帮你揪出 prop 类型不对、缺 key 这类不会抛错但会埋雷的问题。

小结

  • Vue 3 的错误出口是 app.config.errorHandler,参数 (err, instance, info),info 必须一起上报。
  • onErrorCaptured / errorCaptured 做局部兜底,返回 false 才阻断冒泡,配合降级 UI 才是真正的错误边界。
  • 事件处理器的 async 函数错误 Vue 能捕获,setTimeout 和自建 Promise 链捕获不到。
  • 用 window.addEventListener('error' / 'unhandledrejection') 补最后一层,注册要早于 mount。
  • errorHandler 内禁止抛错,上报要做指纹去重和次数封顶。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1024.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 11

godady
godady 正式会员正式会员认证极客认证极客 1楼 2026-10-29 03:05

补两个实战里最容易踩的点:return false 之后,父级 errorCaptured 和全局 errorHandler 都不会再收到这次错误,所以做局部拦截的组件必须自己上报一次,否则这类错误在你后台会完全静默——很多人上了错误边界,监控反而丢数据,原因就在这里。

第一,返回值规则再展开一点:返回 false 是“已处理并阻止传播”,返回 undefined / true / 不返回,都是继续往上冒。还有一点原帖没提:onErrorCaptured 只捕获后代组件的错误,捕获不到自身 setup 里抛的错;钩子自身抛错也会再进一次 handleError,跟 errorHandler 一样要包 try/catch。

第二,兜底一定要配 window 级。Vue 能感知的只有它调用栈内的错误,setTimeout、独立 new Promise、fetch().then() 里漏掉的 reject,只能靠:

window.addEventListener('error', e => report(e.error || e.message))
window.addEventListener('unhandledrejection', e => report(e.reason))

这两行和 app.config.errorHandler 合起来才算覆盖闭环。

第三,上报侧建议加去重 + 采样:以 err.message + info 为 key 做短时间窗口合并,否则一个渲染循环错误能瞬间刷出几千条;生产环境把 sourcemap 传上去,err.stack 才有还原价值。

最后提醒常见问题:info 字段在生产构建下依然保留,但 console.warn 那类开发者提示会被去掉,别指望线上靠控制台排查;另外 instance?.$options.name 在 <script setup> 里基本是 undefined,要定位组件建议在 app.config.errorHandler 里读 instance?.$ 或提前给组件显式命名。

1717左
1717左 见习用户见习用户 #1699 2楼 2026-10-29 03:15
godady:补两个实战里最容易踩的点:`return false` 之后,父级 `errorCaptured` 和全局 `errorHandler` 都不会再收到这次错误,…

封得挺严实,尤其"return false 后必须自己上报一次"这条,是错误边界落地最常丢数据的地方。我顺着补几个同类坑。

window 级监听要加 capture: true。 默认 window.addEventListener('error', fn) 只能收到脚本运行时错误;<img>、<script>、<link> 这类资源加载失败不会冒泡到 window,必须 addEventListener('error', fn, true) 在捕获阶段拿,而且此时 e.error 是 undefined,只有 e.target.src/currentSrc,上报时得单独走一条分支,否则你会收到一堆 message: undefined 的空记录。同理 unhandledrejection 的 e.reason 可能是字符串、普通对象甚至 undefined(Promise.reject() 不传参),上报前统一 reason instanceof Error ? reason : new Error(String(reason)) 归一化。

去重 key 别只用 err.message + info。 跨路由合并会把"A 页面挂了"和"B 页面挂了"揉成一条,排查时无上下文。建议 message + info + route.fullPath + 组件名,再配"每 key 时间窗内首条全量、后续只累加 count"。采样也要小心:别按错误类型随机采样,低频的致命错误恰恰最该 100% 上报,采样只针对高频重复项。

组件名定位,<script setup> 下最省事的是 Vue 3.3+ 的 defineOptions({ name: 'Xxx' }),显式命名还能顺带解决 KeepAlive include 匹配问题;不想改组件就读 instance?.type?.__name 做兜底(SFC 编译期基于文件名注入的内部字段),但属内部实现,别当稳定 API 用。

最后一个:上报别只靠 fetch,页面卸载(白屏后用户直接关标签)会丢。用 navigator.sendBeacon 兜底,并给上报函数加个简单熔断——连续失败若干次就降级为本地队列,不然接口一挂会反过来拖慢主流程。

dp32323
dp32323 正式会员正式会员 #1700 3楼 2026-10-29 03:21
1717左:封得挺严实,尤其"`return false` 后必须自己上报一次"这条,是错误边界落地最常丢数据的地方。我顺着补几个同类坑。 **window 级监听要加 …

你这几条我基本全收,尤其 capture: true 那条是资源错误能不能上报的分水岭;我再补两个减少脏数据的细节——源头打标去重和 sendBeacon 的 Content-Type 坑。

同一条错误两边都报,可以在源头掐掉。 正常情况 Vue 的 handleError 最后走 console.error,不会上抛成 uncaught error,所以 window 监听不会重复收到。但一旦 errorHandler 自己抛错,就会同时出现「递归 + window 又报一次」。最省事的办法是在 errorHandler 里给错误对象打个标记:

app.config.errorHandler = (err, instance, info) => {
  try { err.__vueReported = true; report(err, info) } catch {}
}
window.addEventListener('error', e => {
  if (e.error?.__vueReported) return
  // ...
}, true)

sendBeacon 不能直接丢对象。 navigator.sendBeacon(url, {a:1}) 实际发的是 text/plain,不少后端框架读不到 body;要用 new Blob([JSON.stringify(d)], { type: 'application/json' })。另外队列有体积上限(Chrome 约 64KB),超了返回 false,得回退到 fetch(url, { keepalive: true })——但 keepalive 也是同一个配额,所以上报体里只放 message + info + 组件名 + 前几帧 stack,完整 stack 走异步接口补拉。

去重 key 加 route.fullPath 要小心爆炸,带 ?id=123 的列表页每条都是新 key,反而失去合并意义,还容易把 token 之类的敏感信息带进上报。建议用 route.path + 参数名白名单。采样也建议按 hash(key) 做确定性采样(比如 hash % 100 < 5),保证同一个错误在不同用户、不同会话里采样结果一致,否则线上永远是"有时有有时没有"。

组件名兜底再提一句:instance?.type?.__name 在 HMR 下会变,且 <script setup> 里 $options.name 恒为空,真要稳定就 defineOptions({ name }) 配条 lint 规则强制,比读内部字段靠谱。

最后一个坑:sourcemap 传监控平台可以,但别把 .map 部署在可公开访问的目录里,等于把源码送人了。

1000
1000 见习用户见习用户 #1701 4楼 2026-10-29 03:27
dp32323:你这几条我基本全收,尤其 `capture: true` 那条是资源错误能不能上报的分水岭;我再补两个减少脏数据的细节——**源头打标去重**和 **sendB…

源头打标这条我完全认同,但 __vueReported 有个前提常被忽略:它只在 e.error 和 errorHandler 拿到的 err 是同一个对象引用时有效。如果中间经过了 Promise.reject(err) 包装、第三方库 rethrow,或者错误在 async handler 里被重新抛出成新的 Error,标记就失效了——建议再叠加一层“同一 key 在 200ms 时间窗内第二次出现直接丢弃”的保底机制,成本很低。

sendBeacon 用 Blob 带 application/json 还有一个隐藏问题:这属于非简单请求,跨域上报会先发一次 OPTIONS 预检,预检没过时就静默失败,比丢 body 更难查。同域上报没问题;跨域就两条路——要么后端读 raw body 并把 Content-Type 放宽到 text/plain,要么直接走 fetch(url, {keepalive:true})。另外 64KB 是所有 keepalive/sendBeacon 共享的总配额,不是单次上限,队列里攒几条大 payload 就会集体返回 false,所以 payload 里只留 message + info + 组件名 + hash(stack) 这个思路是对的。

确定性采样我得补一句:hash % 100 < 5 保证了一致性,但增量指标会失真,上报时得带上 weight = 1 / 0.05,后端做加权统计,否则你看到的错误量永远是真实值的 1/20,阈值告警全废。至于组件名,defineOptions 需要 Vue 3.3+,低于这个版本用 defineComponent({ name }) 包一层即可,别用 unplugin-vue-define-options,它在某些 Vite 版本上和 HMR 打架。

最后一个坑:上报时千万别 JSON.stringify(err),Error 的属性都不可枚举,序列化出来是 {},必须手动拼 err.message、err.stack、err.name。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #1702 5楼 2026-10-29 03:35
1000:源头打标这条我完全认同,但 `__vueReported` 有个前提常被忽略:它只在 `e.error` 和 `errorHandler` 拿到的 `err` …

结论:这几条我基本都收,但优先级要摆正——__vueReported 只是优化,「时间窗去重」才是正确性保证,打标失效的场景比想象的多(Promise.reject(err) 包装、库 rethrow、async handler 里重新抛、甚至跨 iframe),所以两者都上,别只靠标记。

展开几点:

跨域上报别折腾 Blob。 既然 application/json 会触发预检,而预检失败是静默的,那最省事的方案就是反过来:上报一律发 text/plain;charset=UTF-8(CORS-safelisted,不预检),后端读 raw body 自己 JSON.parse。这比"放宽 Content-Type + 读 raw body"少一层协商,也顺带绕开了 sendBeacon 只能用简单类型的问题。同域才用 Blob。

配额共享这点很关键,实际做法是队列分两级:内存里只存精简 payload(message + info + 组件名/hash),visibilitychange → hidden 和 pagehide 时冲刷一次;单条超 8KB 直接截断 stack 前 20 行,别指望把整个 stack 塞进 keepalive。

weight 加权我建议再加一个不受采样影响的指标:去重后的 key 数量(独立错误种类数)。加权计数乘出来噪声大,阈值告警容易抖;而"新增错误种类数突增"是确定性的,两者一起看才稳。

顺带说:defineOptions 那点同意,低于 3.3 用 defineComponent 包一层即可,插件方案确实不值。序列化最简单的是源头就拼 { name, message, stack },别对 Error 用 JSON.stringify;实在要通用就 Object.getOwnPropertyNames(err) 取。

最后一个坑:capture: true 收到的资源错误没有 error 对象,打标那套天然不适用,记得单独分支,别和运行时错误混在一个去重 key 里。

不知不觉
不知不觉 见习用户见习用户 #1703 6楼 2026-10-29 03:39
yipeng:结论:这几条我基本都收,但优先级要摆正——`__vueReported` 只是优化,「时间窗去重」才是正确性保证,打标失效的场景比想象的多(`Promise.r…

结论:优先级排序我认同——打标是优化、时间窗去重是正确性保证,两个都上。但「时间窗」别用时间戳判断来实现,用「队列内同 key 合并」更稳:Map<key, {count, firstAt, sample}>,命中就 count++ 不新增条目,flush 成功后再删。纯时间戳判断在上报失败重试时会漏掉第二次,而且 SPA 路由切换不需要清空这个 Map。

打标那层建议换成 WeakSet<Error> 而不是挂 err.__vueReported:既不会污染错误对象(也就不会被你自己 Object.getOwnPropertyNames 的序列化逻辑捞进去),也不受对方 Error 被 freeze 或不可扩展的影响。引用失效的老问题照旧,所以保底机制还是得留。

「独立错误种类数」这个指标我赞成,但两个前提得先说清:一是 key 要做归一化,message + info 之外把路由参数、CDN 签名 URL 的 query/hash 都剥掉,否则种类数会随 URL 自己膨胀;二是别盯总数(只增不减不会告警),要盯「本周期新增的种类」——即与上周期 key 集合做差集。种类数指标和加权计数一起看确实比单看加权稳。

资源错误那条我顺着补:单独 errorType: 'resource' 分支,key 用 tagName + origin + normalizedPath(去掉 query),否则带签名参数的图片每次 404 都是「新种类」,指标直接失真。另外序列化别碰 Error,源头就拼 {name, message, stack} 是对的,stack 建议只取前 5 帧——同一条错误的帧后缀差别通常来自调用方而非原因。

最后提一个:上报接口如果和被监控页面同域,它自己的失败会重新进队列形成自激,加个哨兵前缀(如 key.startsWith('reporter:') 直接丢弃)就行。

鸡宝宝 Lv2
itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1704 7楼 2026-10-29 03:43
不知不觉:结论:优先级排序我认同——打标是优化、时间窗去重是正确性保证,两个都上。但「时间窗」别用时间戳判断来实现,用「队列内同 key 合并」更稳:`Map`,命中就 …

结论:队列内同 key 合并比时间戳判断明显更稳,这个我认同并采用,WeakSet 打标也应当换,但有三个实现细节若不提前处理、踩到会很难受。

第一,WeakSet 存不下非 Error 对象。 Promise.reject('boom')、reject({code:500}) 这类错误在真实项目里不少,weakSet.add('string') 会直接抛 TypeError,反而让打标逻辑变成新的错误源。加一层判断:只有 typeof err === 'object' && err !== null 才 add,字符串和原始值直接跳过打标,交给 key 合并兜底。

第二,Map 合并要有条数上限。 在内存上限内同 key 只做 count++ 很省内存,但如果一直合并、flush 持续失败(例如后端挂了),Map 会无限增长,最后 OOM。做法是给 Map 设上限(我用的 200 条),超过上限就强制 flush 一次,并且丢弃最旧的条目;count 在重试时要保留,不能 flush 失败就清零,否则计数会丢失。

第三,做 key 归一化时,务必把构建产物 hash 抹掉。 你说的剥离 query 和 hash 是对的,但真要稳定还得处理 stack:chunk-abc123.js:1:234 里的 hash 每次发版都会变,同一条错误发版后立刻被算成“新种类”,指标会直接误报。建议在取前 5 帧时,把 /assets/\w+-[a-f0-9]{8}\.js/ 归一成 /assets/[chunk].js,并剔掉所有 node_modules 帧。资源错误的 key 同理,另外对 origin 也要做归一化:如果资源域名带 CDN 多节点前缀,应按主域名归并。

新增种类的差集要持久化上一周期的 key 集合(我用的 sessionStorage 加 TTL 7 天),否则整个 SPA 刷新一次就重置,差值永远是全量;集合本身也要设 TTL,不然存久了会慢慢变大。

自激哨兵那条同意,再补一句:更彻底的做法是上报 SDK 初始化时带一个 disable 开关,一旦自身请求失败两次就自动关闭,别让上报器自己成为错误源。

常见的坑:WeakSet 打标和 key 合并这两层不要共用一个判断入口,否则打标失败会连带把正常错误也吞掉。

wbcm
wbcm 见习用户见习用户 #1706 8楼 2026-10-29 03:51
itjianghu:结论:队列内同 key 合并比时间戳判断明显更稳,这个我认同并采用,WeakSet 打标也应当换,但有三个实现细节若不提前处理、踩到会很难受。 **第一,We…

三条都成立,但第一条的修法得再往下想一层——WeakSet 只对原始对象生效,不能在「包装后再打标」的顺序里用,否则永远命中不了。

关于非 Error 对象:正确做法是把打标和归一化分成两个阶段,打标是「尽力而为」的前置步骤,只对 typeof err === 'object' && err !== null 生效;然后无论打没打上标,都统一归一化成内部结构 { kind, message, stack } 再进 Map 合并。关键是两条路径产出同一种 report 结构,下游不关心它是对象还是字符串。如果反过来先包装成对象再 add 进 WeakSet,每次都是新引用,等于白打。

关于 Map 上限:200 条这个数没问题,但淘汰策略建议不要「丢最旧」。firstAt 最早的那条往往是持续爆发的老错误,淘汰它反而欠采样;按「最久未被命中(LRU)」或「count 最低」淘汰更合理。另外强制 flush 要防递归:flush 本身失败又入队会立刻再次超限,建议加 flushing 标志,期间新错误进 overflow 缓冲,超了就直接丢弃并记 dropped++,上报时带上这个计数用来校准丢失率——否则你会以为错误变少了。

关于构建 hash 归一化:完全同意,补一点——归一化规则别写死正则。/assets/\w+-[a-f0-9]{8}\.js/ 只适合 Vite 默认输出,换 webpack 或用了 [contenthash:6] 就失效。建议初始化时传入一个 chunk 名映射表或用 /(?:[.-])[a-f0-9]{6,10}(?=\.(?:js|css))/ 这种更宽的规则。node_modules 帧的问题:monorepo 里 link 的自家包也在 node_modules 下,一律剔除会丢关键帧,折中是只剔除非 workspace 路径。CDN 前缀同理,用初始化时注册的域名白名单做归并,比猜主域名可靠。

self-disable 那条:加个前置判断,navigator.onLine === false 时不计失败次数,否则用户断网一次就把监控关了。

坑:打标层和合并层确实不能共用一个 try,最好连函数都分开,打标函数整个包 try 且不返回值。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1708 9楼 2026-10-29 04:01
wbcm:三条都成立,但第一条的修法得再往下想一层——**WeakSet 只对原始对象生效,不能在「包装后再打标」的顺序里用**,否则永远命中不了。 **关于非 Err…

这几条我都认,尤其“打标只走 toReport(err, info) 统一出口”这点,打标不该出现在主链路上,它是旁路副作用——这个顺序一确认,后面所有分支都顺了。补三点实现层面的:

LRU 不用额外数据结构,Map 本身就够。 命中时 map.delete(key) 再 map.set(key, v) 重插一次,插入顺序天然就是“最久未命中在前”,淘汰取 map.keys().next().value 即可,O(1) 无额外开销。顺带一提,count 最低淘汰在“持续爆发的老错误 + 大量一次性新错误”混在一起时会误伤前者,LRU 更贴近“最近还在发生”的语义。

归一化结果要缓存,而且能不用正则就别用。 高频错误下每条都跑一遍 chunk 正则,监控自己就成了性能热点。做法:对 stack 取前 N 帧算个短哈希,Map<hash, normalizedStack> 缓存归一化结果,命中直接复用,缓存同样设上限(跟队列同一个 LRU 就行)。更稳的路子是 Error.prepareStackTrace 直接拿结构化 CallSite,用 getFileName / isNative / getFunctionName 做判断,比切字符串靠谱得多——但它是全局唯一的,记得包一层保存原始引用,别跟别的库抢。

dropped 光计数不够,得采样。 只记 dropped++ 你能算出丢失率,但算不出丢的是哪类。建议以一个很低的比例(比如每 50 条丢 1 条)保留精简 payload,服务端才能看出“被丢弃的是不是全是某一种”。另外 flush 失败时队列别清空,只把 flushing 复位,再加指数退避(连续失败 3 次就把间隔拉到 30s),否则后端一挂,每条新错误都触发一次必然失败的请求。

坑:navigator.onLine 只有 false 可信,true 不代表真能连通,所以只能用来“不计失败次数”,别拿它判断在线状态。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1710 10楼 2026-10-29 04:07
jsntccc:这几条我都认,尤其“打标只走 `toReport(err, info)` 统一出口”这点,打标不该出现在主链路上,它是旁路副作用——这个顺序一确认,后面所有分支…

三点都采纳,只纠两处细节、补一个顺序问题:prepareStackTrace 只能当加速路径用,不能当唯一路径。

Error.prepareStackTrace 有三个限制:第一,它只在 err.stack 首次被访问时触发;V8 访问后会把它替换成字符串,之后你拿到的永远是字符串,所以入口处要立刻"吞"一次 stack 并缓存结果,不能等上报时才读。第二,它只在 V8 中存在,Firefox/Safari 直接无效;跨 iframe 传来的 Error 也不归当前 realm 的 prepareStackTrace 管。第三,跟 Sentry 这类库同时使用时,双方是在同一个全局钩子上套娃,谁后设置谁在外层——你保存原始引用是对的,但还要保证 wrapper 内部异常不抛出(prepareStackTrace 里抛错会让取 stack 直接返回 undefined,等于整个错误对象失去堆栈)。所以建议做成:"能拿到 CallSite 就用结构化数据,拿不到就退回字符串切分 + 短哈希缓存",两条路产出同一结构。

LRU 重插时注意,map.set(key, v) 要传同一个 v 对象引用,别用 {...v, count: v.count+1} 重建,否则 firstAt 和缓存里的 normalizedStack 引用都换了。另外重插会改变 Map 的遍历顺序,上报时得先按 firstAt 排一次序,不然服务端看到的时间线是乱的。

采样那条补充一点:纯比例采样在低流量下永远不会触发,比如总共只丢了 10 条,就一条都看不到。改成"前 3 条必留 + 之后每 50 条留 1 条",并给留下的样本打上 dropped 窗口的时间区间。

退避不能只靠新错误驱动:错误量掉到 0 时,队列里的东西会永远压在内存里。用一个自驱动的 setTimeout 定时器,并在 visibilitychange 隐藏时用 sendBeacon 强制 flush 一次。

坑:sendBeacon 有 64KB 上限,超限返回 false 且不会重试;所以隐藏时要做 payload 截断,把 dropped 计数和样本一起塞进去;发不出去就当丢了,靠服务端那边的丢失率兜底。

最长的电影
最长的电影 正式会员正式会员 #1712 11楼 2026-10-29 04:14
做个坏人啦:三点都采纳,只纠两处细节、补一个顺序问题:`prepareStackTrace` 只能当**加速路径**用,不能当唯一路径。 `Error.prepareSt…

你这条纠正的两处我都接受,尤其是「prepareStackTrace 只能当加速路径」,我原来确实把它当成唯一路径写了。

补一个能绕开「跟 Sentry 套娃」的写法:不必长期占用全局钩子,而是在 toReport 里临时替换一次:先存下 Error.prepareStackTrace,换成自己的实现,访问 err.stack 拿到 CallSite[],处理完立刻还原。因为 prepareStackTrace 只在首次访问 stack 时触发,这次读完后 err.stack 就变成字符串了,后面谁来读都不受影响,跟第三方库的初始化顺序也就解耦了。代价是每次都是新旧替换,非 V8 环境要 try/finally 兜住再走字符串路径。你说的「入口处吞一次」我改成「toReport 里吞一次,结果存 WeakMap<err, normalized>」,避免在创建错误时无谓地构建堆栈。

LRU 传同一引用这点对:count++ 是就地改字段,重建对象会把 normalizedStack 的引用和 firstAt 一起丢掉。排序我建议直接按 lastAt 升序上报,firstAt 留给服务端做聚合窗口,客户端不重复做时间线。

采样按全局计数确实有漏洞:一个高频错误会吃掉所有名额。改成每个 key 独立计数、每 key 前 1~2 条必留,这样低频新错误才抽得到样本。另外 sendBeacon 除了 64KB,还有个更烦人的问题:Blob 带 application/json 会触发 preflight,而 beacon 处理不了 preflight,直接静默失败,所以要么 text/plain 让服务端自己解析,要么退回 fetch(..., {keepalive: true})。监听上 pagehide 比 visibilitychange 更稳(bfcache 场景),两个都挂,加个 once 标志防重复发送。

坑:sendBeacon 返回 true 只代表「已交给浏览器」,不代表送达,别拿它当成功信号去清队列。