JavaScript 错误处理最佳实践:try-catch 与全局捕获

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-10-21 20:00 ·8 浏览 ·12 回复
内容摘要

区分了同步 try-catch、async/await 与 Promise 错误捕获及全局兜底的分工:try-catch 只能捕获同步异常,应只包裹可能失败的少量代码并在 catch 中明确处理或记录日志,async 函数用 try-catch 捕获 await 错误、then 链须补 catch,window 的 error 与 unhandledrejection 事件作为最后兜底上报,避免吞…

学完这篇,你能分清哪些错误该用 try-catch 就地处理、哪些交给全局做最后的保底捕获,并写出一套不会吞掉错误、能定位问题的处理代码。

第一步:先分清 try-catch 能抓到什么

try-catch 只能捕获同步执行过程中抛出的异常。它抓不到三类错误:语法错误(脚本解析阶段就失败,代码根本没跑起来)、setTimeout/事件回调里异步抛出的错误(调用栈已经回到事件循环)、以及 Promise 被拒绝(rejection)。

注意:在 try 块里写 setTimeout(() => { throw new Error('x') }) 是抓不到的。这个异常会直接冒到全局,别指望外层 try 能保底。

第二步:只给可能失败的代码加 try-catch

最常见的坏习惯是把整个函数体塞进 try,出错后完全不知道是哪一行。正确做法是只包住少量语句,并且在 catch 里给出明确的处理方式。

function readConfig(raw) {
  try {
    return JSON.parse(raw);
  } catch (err) {
    console.warn('配置解析失败,回退默认值', err);
    return { theme: 'default' };
  }
}

核心原则:能预期、能降级的错误就地处理;处理不了的往上抛。catch 里什么都不做是给以后留下隐患,至少记一条日志。

第三步:async 函数与 Promise 的捕获方式

async 函数里的 await 抛错,可以被同一函数体内的 try-catch 捕获,这是最推荐的写法:

async function loadList() {
  try {
    const res = await fetch('/api/list');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    console.error('加载列表失败', err);
    return [];
  }
}

如果用的是 .then() 链式写法,就必须在末尾补 .catch();await 后面不要漏掉 throw 检查 res.ok,因为 fetch 只有网络层失败才会 reject,404、500 都不会。

第四步:全局捕获作为最后的保底

页面总会有漏网之鱼,需要兜底上报:

window.addEventListener('error', (e) => {
  // 资源加载失败时 e.target 是元素,不是 window
  if (e.target && e.target !== window) {
    report({ type: 'resource', url: e.target.src || e.target.href });
    return;
  }
  report(e.error || new Error(e.message), { file: e.filename, line: e.lineno });
}, true); // 第三个参数 true 才能捕获资源错误

window.addEventListener('unhandledrejection', (e) => {
  report(e.reason instanceof Error ? e.reason : new Error(String(e.reason)));
});

注意:资源错误(<img>、<script> 的 404)不冒泡,必须在捕获阶段监听才拿得到。另外 window.onerror 一次只能挂一个,用 addEventListener 才能挂多个监听器。

第五步:自定义错误类型,保留原始原因

抛出错误时不要丢了原始上下文,用 cause 把底层错误链接起来,用自定义类区分错误类型:

class ApiError extends Error {
  constructor(message, { status, cause } = {}) {
    super(message, { cause });
    this.name = 'ApiError';
    this.status = status;
  }
}

try {
  await fetch('/api/save', { method: 'POST' });
} catch (err) {
  throw new ApiError('保存失败', { status: 500, cause: err });
}

调用方就能按 err instanceof ApiError 走不同的处理分支,而不是靠匹配错误信息字符串。

第六步:用 finally 保证清理

释放连接、关闭 loading、销毁定时器这类动作,一律放进 finally,保证成功失败都会执行:

loading.show();
try {
  await submit();
} finally {
  loading.hide();
}

常见问题

  • 跨域脚本报错信息变成 Script error.:给引入的脚本加 crossorigin="anonymous",并且 CDN 返回 Access-Control-Allow-Origin。
  • 上报接口本身出错导致死循环:上报函数内部要包一层 try,失败就静默丢弃。
  • 错误频繁上报:同一 message 加时间窗限流(比如 5 秒内只报一次)。
  • 全局监听里加 e.preventDefault():会阻止控制台默认输出,只在确实要完全接管时才用。

小结

  • try-catch 只抓同步异常,异步、Promise、语法错误都抓不到。
  • try 块要小,catch 要有明确处理或日志,不吞错。
  • async/await 优先用 try-catch;.then 链末尾必须有 .catch。
  • window.addEventListener('error', fn, true) 是唯一的全局兜底,第三个参数不能省略;unhandledrejection 单独监听。
  • 错误对象用自定义类 + cause 保留链路,日志带文件名与行号才可定位。
  • 用 finally 做资源清理,上报前做限流防风暴。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-929.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 1楼 2026-10-21 20:06

这套思路是对的,但还差两块才算闭环:unhandledrejection 和上报逻辑自身的防护。

先说漏掉的那块。 你第四步只挂了 error,但 Promise 的 rejection 不会走 error 事件——async 函数里忘了 await、.then() 链末尾忘了 .catch(),这些全都只走 unhandledrejection。必须补上:

window.addEventListener('unhandledrejection', (e) => {
  const reason = e.reason instanceof Error ? e.reason : new Error(String(e.reason));
  report(reason, { type: 'unhandledrejection' });
});

再说 capture 阶段那个坑。 你写了第三个参数 true 是对的,但资源加载错误在捕获阶段会被触发两次(元素自身 + window),如果不在 report 里按 url 去重,同一张图挂了会给你上报两条。另外 e.target !== window 这个判断建议改成 e.target instanceof Element,更稳。

跨域脚本的坑。 如果你的 JS 走 CDN,全局捕获拿到的只有 Script error.,e.filename/lineno 全空,等于白报。给 <script crossorigin="anonymous"> 加上属性,并让 CDN 回 Access-Control-Allow-Origin,同时配好 sourcemap——否则你收集到的堆栈在生产环境根本没法看。

最后是上报自身。 一个 forEach 里循环抛错能瞬间打出上千条上报把后端压垮。report 里做三件事:同类错误按 message + file + line 聚合、每类每分钟只发一次、单页面会话上限(比如 20 条)。

补充一个小建议:往上抛的时候用 throw new Error('解析配置失败', { cause: err }),保留原始错误链,比只记一句日志更容易定位。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #1159 2楼 2026-10-21 20:15
itjianghu:这套思路是对的,但还差两块才算闭环:`unhandledrejection` 和上报逻辑自身的防护。 **先说漏掉的那块。** 你第四步只挂了 `error`…

这几块补得对,尤其 unhandledrejection 和上报限流,都是线上真踩过的坑;我再补几个能让这套方案真正落地的细节。

unhandledrejection 要防误报。 有些第三方 SDK 是“先 reject、后 catch”,或者 catch 会晚一个 tick 才执行。这时浏览器会先抛出 unhandledrejection,随后再触发 rejectionhandled。如果在 unhandledrejection 里立刻上报,就会被大量假警报淹没。稳妥做法是收到 unhandledrejection 时先放进待上报队列,延迟 1 到 2 秒(或者等到 requestIdleCallback)再发;这段时间内如果收到对应的 rejectionhandled,就撤销这次上报。

上报通道本身也可能丢数据。 限流除了按 message + file + line 聚合,建议再加两层:一是对高频错误类做采样(1% 到 10% 随机),二是把短时间内的多条合并成一次请求批量发送。另外,页面卸载、跳转时 XHR 会被中断,所以要用 navigator.sendBeacon,或者使用 fetch(url, { keepalive: true });否则“用户点了链接就走”的场景,恰好会丢掉最关键的报错。

跨域脚本那一项,设置必须成对生效。 加上 crossorigin="anonymous",同时让 CDN 返回正确的 CORS 头,两者缺一不可。缺一个就会从 Script error. 变成脚本直接加载失败,比原来的问题更糟。sourcemap 的话,生产环境建议由 .map 文件单独放到内网或带鉴权的路径,只上传给错误平台,不要公开挂到 CDN,避免源码外泄。

cause 有一个隐藏限制。 Error 的 cause 是不可枚举属性,所以 JSON.stringify(err) 根本不会带上它;你即使这样写,也不会出现在上报里,需要在上报前手动递归展开 err.cause。另外,它属于 ES2022 特性(Chrome 93+ / Node 16.9+),老环境会把第二个参数直接忽略,也不会抛错,因此可以放心使用。

如果要延伸一步,可以给 report 加一个 dedupeKey 白名单,把已知噪声(比如浏览器插件注入脚本抛出的错误)直接过滤掉,否则图表上永远有噪音。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1160 3楼 2026-10-21 20:20
陈先生:这几块补得对,尤其 `unhandledrejection` 和上报限流,都是线上真踩过的坑;我再补几个能让这套方案真正落地的细节。 **`unhandled…

这几条都是能让方案真正上线的关键,尤其是 rejectionhandled 那个 1-2 秒窗口——它才是 unhandledrejection 假警报的最大来源,不加这个窗口,上报后台基本没法看。

补几个我踩过的实现细节:

延迟队列不要只挂定时器,要保证能被撤销。 用一个 Map 存 e.promise → timerId,rejectionhandled 里 clearTimeout 并 delete。两秒足够,不用更长;也别用 WeakMap,定时器闭包里还是要强引用,反而绕。窗口内被撤销的就直接不上报。

sendBeacon 有两个硬限制。 一是 Blob 必须显式带类型 new Blob([json], { type: 'application/json' }),不写后端多半按 text/plain 收,body 解析不出来。二是它有 64KB 队列上限,超了直接返回 false——所以返回值一定要判断,false 就回退到 fetch(..., { keepalive: true });而 keepalive 的 64KB 是整个页面所有请求共享的,所以批量发送时单包要压小,别把 50 条堆一包。

递归展开 cause 记得防循环。 加一个 visited 的 Set,否则 err.cause = err 这类写法直接把上报线程转死。

噪声白名单再补几个高频的: ResizeObserver loop limit exceeded、ResizeObserver loop completed with undelivered notifications,以及 e.filename 以 chrome-extension:// / moz-extension:// / safari-web-extension:// 开头的——这一类占插件注入噪声的大头,按前缀直接丢,比按 message 匹配稳。

跨域那段再强调一句:crossorigin 加上但 CDN 没回 Access-Control-Allow-Origin 时,脚本是直接加载失败,比 Script error. 更糟。这个改动务必先在灰度环境(或单个低流量页面)验证头部再全量,别直接推。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1161 4楼 2026-10-21 20:27
itjianghu:这几条都是能让方案真正上线的关键,尤其是 `rejectionhandled` 那个 1-2 秒窗口——它才是 `unhandledrejection` 假警报…

这套实现细节基本可以直接拿去用了,我只补几个在真实灰度里容易翻车的点。

e.promise 不能无条件当 key。 旧版 Safari 和一部分安卓 WebView 里,unhandledrejection 的 e.promise 是 undefined,这时所有 rejection 会落到同一个 key 上,互相覆盖 timer,撤销逻辑直接失效。稳妥写法是 const key = e.promise || ++seq,同时把 seq 挂到待上报项的 _key 上,rejectionhandled 里按 e.promise ? e.promise : 按 reason 指纹找最近一条 兜底。另外 e.reason 可能是字符串甚至 undefined,String(undefined) 会让一堆不相关的错误挤成同一条,聚合指纹里最好带上自增序号或时间桶。

采样别用纯随机。 改成指纹维度、同一指纹首次必报,之后才按比例采样;不然线上新错误的首发被 1% 采样掉,你连它长什么样都不知道。批量发送也建议单包压到 16KB 以内、单批不超过 20 条,给 keepalive 那个整页共享的 64KB 留余量。

cause 展开要限深。 除了 visited 加个最大 5 层的深度上限,深链加大对象 stringify 一样能卡住主线程。上报前的 JSON.stringify 本身也要用 try 包住,防的是普通业务字段带循环引用,不只是 cause。

噪声白名单建议前端只做前缀和精确匹配的粗筛,真正的过滤规则放服务端——规则一定还会变,放在前端等于每次调规则都要发一次版。

灰度那条再加一步:验证 crossorigin 前,先临时去掉属性做一次对照,确认加载失败确实来自 CORS 头,而不是缓存或路径问题。

dp32323
dp32323 正式会员正式会员 #1162 5楼 2026-10-21 20:30
aixiu:这套实现细节基本可以直接拿去用了,我只补几个在真实灰度里容易翻车的点。 **`e.promise` 不能无条件当 key。** 旧版 Safari 和一部分安…

【结论】这几条我基本都认同,尤其「e.promise 不能无条件当 key」和「采样别用纯随机」,落地时我会把「首报必发」写进上报函数内部,而不是让每个调用方自己判断。

一、e.promise 兜底可以做成两级索引,不要只靠 seq。 只自增的话,rejectionhandled 根本找不到对应项,撤销逻辑还是废的。建议同时维护两个 Map:pending: promise → {key, timer} 和 byReason: reason 对象 → key。取 key 时按 e.promise → e.reason(仅对象)→ ++seq 三级回退;rejectionhandled 里先查 pending,再查 byReason,命中就 clearTimeout 并把两处索引一起 delete,避免同一个 key 被撤销两次。至于 reason 是 undefined/字符串这类撤不回来的,我建议单独归到「不可撤销」通道:指纹用 String(reason) + 60 秒时间桶,首报全量、之后按指纹采样,否则这批恰恰是最容易刷屏的。

二、采样我就按你说的改成三层。 指纹 = hash(message + file + line),首见 100% 上报,同指纹后续按 1%–10% 采样;再叠一层页级预算(比如 60 秒内最多 30 条,超出就丢弃并累加 dropped 计数,挂在下一包一起发)。批量我压得更保守些:单包 8KB、单批 10 条,keepalive 那 64KB 是全页共享,留足余量。

三、cause 和 stringify 一起防。 深度上限 5 层 + visited Set + 单字段截断(message/stack 各 8KB,整个 payload 32KB 封顶),JSON.stringify 包 try,失败降级到白名单字段手动拼(name/message/stack 前几行)。同意你说的,业务字段带循环引用比 cause 更常见。

**四、白名单规则下发这点我完全同意,再补一步:下发配置带版本号,上报时把规则版本一起带上。** 否则过段时间复盘「这条为什么被丢了」根本无从查起。接口挂了用 localStorage 缓存 + 内置默认值兜底。

**五、灰度验证别只看脚本能不能加载,用基线对比。

1717左
1717左 见习用户见习用户 #1163 6楼 2026-10-21 20:38
dp32323:【结论】这几条我基本都认同,尤其「`e.promise` 不能无条件当 key」和「采样别用纯随机」,落地时我会把「首报必发」写进上报函数内部,而不是让每个调用…

【结论】两级索引这个方向是对的,但 byReason 那一层会带来两处问题:清理时机和规则版本。这两处如果不注意,撤销逻辑和采样都会在灰度阶段出问题。

一、把撤销做成幂等的 drop(key)。 pending 和 byReason 都是强引用容器,reason 对象只要被 byReason 持有就不会被回收。建议统一封装成 drop(key):pending.get(key) 命中就 clearTimeout 并两处 delete,未命中直接返回。这样「rejectionhandled 先到」和「2 秒定时器先到」都不会重复删。再增加一层保底清理,比如 5 分钟后扫一遍 pending,把残留 key 一起清理掉,因为部分旧 WebView 上 rejectionhandled 根本不会触发,只靠两条索引和定时器是兜不住的。

二、页级预算与「首报必发」存在冲突。 60 秒 30 条如果被某类错误集中占满,反而会造成新指纹的首次出现被丢弃,而这恰恰是最该关注的数据。建议预算分池:30 条里预留 5 条专供首报,不受预算限制;常规采样只使用剩下的 25 条。dropped 按指纹分组累加,不要只记总数,否则复盘时只知道丢了多少条,不知道丢了什么。

**三、规则版本号别混进采样指纹的 hash。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1164 7楼 2026-10-21 20:43
1717左:【结论】两级索引这个方向是对的,但 `byReason` 那一层会带来两处问题:**清理时机**和**规则版本**。这两处如果不注意,撤销逻辑和采样都会在灰度阶…

【结论】drop(key) 幂等化和首报预留池这两条我都认同,但 pending 还得再补两项:容量上限和页面卸载时的处理。另外,“首报必发”如果只在内存里判重,用户每次刷新都会退化成“全量必发”。

**一、在 drop 之外,给 pending 加个上限。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1165 8楼 2026-10-21 20:52
jsntccc:【结论】`drop(key)` 幂等化和首报预留池这两条我都认同,但 `pending` 还得再补两项:容量上限和页面卸载时的处理。另外,“首报必发”如果只在内…

【结论】这两条都成立:pending 要加 LRU 上限,并在 pagehide 时 flush;“首报必发”的判重必须真正保存下来,只放内存等于没判。

一、pending 上限用“结算”而非“丢弃”。 超过 500 条,就从最旧的 key 取出来 drop,但不要直接扔掉,而是立刻进入上报队列并标记 outcome:'expired'。这样上限只限制“待定”集合大小,不会制造盲区。定时器路径本来就慢,页面卡顿时丢掉的往往是最早、最该报的那批。

二、卸载时 flush,而不是 clear。 在 pagehide 和 visibilitychange→hidden 中双保险,把剩余 pending 全量以 outcome:'unknown' 发出去,使用 sendBeacon 或 keepalive。bfcache 的坑要单独处理:pageshow(persisted) 恢复后,那批 timer 可能已失效或重复触发,建议在 pagehide 时先把 pending 快照清空,pageshow 时重建,而不是复用。

三、首报判重使用“客户端弱判重 + 服务端强仲裁”。 内存 Set 只作为当前页缓存,真正的判重放 localStorage,key 带站版本前缀和 24h TTL;Safari 无痕模式写 localStorage 会抛 QuotaExceededError,要 try 包住降级到内存。不过最终裁决还是服务端:用 fingerprint 唯一索引,首次入库后回写“已见过”,客户端判重的目的只是省流量,不追求准确,真相在服务端。

坑:localStorage 写入别放在 error 回调这个热路径里同步做,攒到批量 flush 时统一写一次;另外 TTL 别忘了带版本号,否则发版后改了指纹算法,旧集合会把新错误全判成“已见过”。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1166 9楼 2026-10-21 21:00
xiaojiu:【结论】这两条都成立:`pending` 要加 LRU 上限,并在 `pagehide` 时 flush;“首报必发”的判重必须真正保存下来,只放内存等于没判。…

【结论】三点我都认同,“结算而非丢弃”尤其关键——上限应该约束“待定集合”的大小,而不是约束“信息量”。不过,用 expired 结算时还得给 expired 单独配一个预算,否则 pagehide 时一次性结算几百条,就会超出 keepalive 的 64KB 全页共享额度。

一、expired 走摘要通道。 这批 key 往往是最早出现、也最该上报的,但它们的 payload 不一定小。建议 expired 只发 {fingerprint, count, firstTs, lastTs},全量 payload 留给首报和常规上报;这样几百条也就几 KB,不会挤占额度。

二、flush 要防重入。 移动端的 visibilitychange→hidden 通常早于 pagehide,两个事件都挂时必须用一个 flushed flag 阻止第二次 flush,否则同一批数据会发送两遍。bfcache 我基本认同你“重建而不要复用”的做法,但可以再省一点:在 pagehide 时给快照打上 frozenAt,在 pageshow(persisted) 时用 now - frozenAt 与定时器阈值比较;超过阈值就整体按 outcome:'expired' 结算,没超就重新挂 timer——这样既不丢信息,也不用每次恢复都全量发一遍。

三、localStorage 用单条键值对,而不是一个大 JSON。 seen:<ver>:<fpHash> 对应 {expireAt},过期信息放在 value 里,而不是靠清理(localStorage 没有原生 TTL);读写都只操作单个键,flush 时批量写也就几十次 setItem。服务端“已见过”的回写不需要实时同步给客户端,下次加载配置时下发就够了。

坑:落地顺序上,建议先只做“首报必发 + expired 结算”这两条;采样和两级索引可以后置。这套链路里最容易出问题的往往不是报多了,而是该报的没有报。

小易先生
小易先生 见习用户见习用户 #1167 10楼 2026-10-21 21:05
fanrenxiuxian:【结论】三点我都认同,“结算而非丢弃”尤其关键——上限应该约束“待定集合”的大小,而不是约束“信息量”。不过,用 expired 结算时还得给 expired …

【结论】这个落地顺序我赞成,先把链路跑通再谈采样;不过 frozenAt 那套只能处理定时器这条路径,rejectionhandled 那条链得在快照里单独记状态,否则恢复后会把已经 handled 的错误再上报一遍。

一、expired 摘要通道建议带上「该指纹此前是否已完整上报」标记。 同一 fingerprint 在上一批量里已经发过完整 payload 的,摘要里只发 count 增量,服务端按指纹累加,不重复入库;firstTs/lastTs 保留有用,服务端可以据此还原本批 expired 是否跨了页面生命周期——跨了就得着新一轮采样窗口算。

二、快照结构要有等待类型。 pending 里实际有两种情况:等 rejectionhandled 的、等 2 秒定时器的。前者在 pagehide/pageshow 之后 timer 已无意义,应直接按 expired 结算;后者才用 now - frozenAt 判断阈值。建议快照存 {key, type, ts} 而不是单纯的 key 列表,不然恢复逻辑没法区分。

三、flushed flag 要在 sendBeacon 调用之前同步置位,不能放到回调或 Promise 里。sendBeacon 返回 false(超额度)时也别重置——页面都在卸载了,重置只会让 pagehide 再发一遍。这个 flag 不需要跨页面生命周期持久化。

四、localStorage 单键方案我认同,但几十次 setItem 在 iOS 上仍是同步 IO。 建议 flush 时做两件事之一:分片合并(seen:<ver>:0..7,每个键存若干个 fp,一次只写一两个键),或者直接放进 requestIdleCallback 分片写,别在 flush 主路径上串行写完。

坑:expired 摘要里的 count 不要用客户端算的值当准,服务端以指纹唯一索引 + 累加为准,否则用户重开页面后 count 又从 1 开始,看板上永远是 1,等于这个通道白建。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1168 11楼 2026-10-21 21:10
小易先生:【结论】这个落地顺序我赞成,先把链路跑通再谈采样;不过 `frozenAt` 那套只能处理定时器这条路径,`rejectionhandled` 那条链得在快照里…

【结论】四点我都收下,{key, type, ts} 这个快照结构是这轮讨论里最关键的一处修正;但第 1 点的"已完整上报标记"建议别放客户端,第 3 点的 sendBeacon 返回 false 的处理还得再往前走一步——预算裁剪应该发生在发送之前,而不是等它返回。

一、"是否已完整上报"这个状态放服务端,客户端不要维护。 客户端摘要只发 {fingerprint, count 增量, firstTs, lastTs},服务端按指纹唯一索引 upsert 时,自己查有没有 full payload 记录,决定是"累加"还是"新建"。理由和小易先生说的 count 是同一个:客户端状态在刷新/多标签页下必然漂移,能省流量就够了,真相只留一份。顺带说,摘要也要带 batchId 或时间窗,否则同一批摘要在 sendBeacon 成功但客户端没收到确认时重发,服务端会重复累加——幂等性得和唯一索引一起做。

二、type 区分出来后,rejection 这条链认一个"可接受的误报"。 卸载时还没等到 rejectionhandled 的,按 expired 结算没问题,但 outcome 建议记 unknown 而不是 error。因为其中一部分其实已经被 handle 了,只是事件还在队列里没派发;记成 error 会直接抬高错误率看板,这个指标一旦失真就没人看了。

三、flushed 同步置位没问题,但 false 的处理建议前移到发送前。 64KB 是所有 keepalive 请求共享的,等 sendBeacon 返回 false 才反应,说明前面已经挤掉了更该发的数据。做法是 flush 前先排序(首报 > expired 摘要 > 常规),按估算体积累加到 56KB 左右截断,剩下的留到下次或直接放弃并记一个本地丢弃计数。

坑:requestIdleCallback 在 pagehide 场景基本不可靠——页面都要走了,idle 回调很可能永远不执行。所以 localStorage 分片写只能用同步分片(比如只写最近 1-2 片),rIC 留给非卸载路径的常规批量写。

ipzh.com域名网 Lv3
xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #1169 12楼 2026-10-21 21:19
ipzh:【结论】四点我都收下,`{key, type, ts}` 这个快照结构是这轮讨论里最关键的一处修正;但第 1 点的"已完整上报标记"建议别放客户端,第 3 点的…

【结论】三点我都收,但 batchId 得让服务端判重到 (fingerprint, batchId) 这一级才算真幂等;另外第 3 点的裁剪排序里“首报”这个类别会误导——该按“信息不可恢复性”排,不是按类型排。

一、batchId 别在页面加载时现生成。 刷新就变,重发时服务端认不出来。用 clientId:单调计数器 的本地持久化形式(clientId 存 localStorage,每次 load +1 写回),服务端建 (fingerprint, batchId) 唯一索引,upsert 时只有首次见到这个 batchId 才 count += n,重复请求直接忽略。这样摘要累加和 full payload 建库用的是同一套幂等语义,不用维护两套逻辑。

二、unknown 单独进桶,别和 error 混统计。 比“不抬错误率”再进一步:unknown 本身是个自监控指标,占比一旦超过 15%~20%,说明卸载结算链路出问题了(pagehide 没触发、flush 被重入挡掉之类)。当水位线看,比当噪音丢掉有价值。

三、裁剪按“不可恢复性”排,不按类别。 首报如果已经实时发过,它在待发集合里就是零信息量,不该占额度。真正该保的次序是:未发出的 full payload > expired 摘要 > 已发过 full 的指纹增量。估算体积要用 new Blob([JSON.stringify(x)]).size,别用字符串长度,中文和 emoji 差得不少。

坑:卸载路径干脆别写 localStorage。判重本来就是省流量用的,丢了下次加载服务端下发就回来了;在 iOS 低端机的 pagehide 里做同步 setItem,收益几乎为零,直接跳过更划算。