三周。这是那个结账故障从发生到被发现的全部时间。
不是报错,不是崩溃,就是……完不成。仪表盘上没有红线,错误追踪器里没有尖峰。客服工单零零散散地进来——“我点了支付,什么都没发生”——被标记成用户操作失误,然后关掉。
![]()
最后定位到的原因只有两行代码:一个 async 处理函数里的 fetch() 调用,整条链路上没有任何 .catch()。当支付接口返回校验错误时,异步操作被拒绝,处理函数悄无声息地停止执行,而这次拒绝去了——哪儿也没去。没有进到任何人在看的控制台,没有进到 Sentry,没有进到任何人那里。
看起来已经写完的代码
下面这个版本几乎人人都在上线,因为它看起来是完整的:
- 给按钮绑定 click 事件,回调声明为 async
- 先把按钮 disabled 置为 true
- await 一个 fetch 请求,POST 到结账接口,带上购物车数据
- await 把响应解析成 JSON
- 调用展示确认信息的函数
它处理了顺利路径。两次调用都 await 了。没有明显的漏洞。拿一个正常工作的接口去跑,它是对的。
现在让 fetch 被拒绝——网络掉线、CORS 配置错误,或者服务器返回了一个 HTML 错误页而不是 JSON,导致 res.json() 抛错。await 在 async 函数内部重新抛出,函数返回的异步结果被拒绝,而由于从来没有任何东西给它挂上 .catch()——事件监听器不会把它返回给任何人,它只是触发然后走开——这次拒绝就没有处理者。button.disabled 永远停在 true。用户看到的是一个悄悄失效的按钮。除非你知道要去看,否则你永远不会知道它发生过。
为什么你的错误追踪器没抓到它
这是最让人意外的地方:未处理的异步拒绝和运行时错误不是同一个事件,而很多错误监控方案只接上了他们最先想到的那一个。
- 异步操作之外抛出的错误会触发 window.onerror(或者 window 上的 error 事件)——大多数基础错误追踪都会挂这个。
- 一个被拒绝、又没有任何人挂上去捕获的异步操作,触发的是另一个事件:unhandledrejection。
如果你从来没监听它,它发生过的唯一迹象就是控制台里的一行——Uncaught (in promise) TypeError: ...——除非开发者工具已经打开、而且有人正好在看对的那个标签页,否则没人会看到。
像 Sentry 这样的完整错误追踪 SDK 默认会帮你接上这个事件。而手写的 window.onerror = reportError 不会。那个结账故障,就在这道缝隙里活了三周。
补上缺口的那一个监听器
修复方式是一个全局监听器,它在所有主流浏览器里已经被支持多年:
- 监听 window 上的 unhandledrejection 事件
- 事件对象的 reason 属性就是异步操作被拒绝时的值——通常是一个 Error,但不保证,因为 JavaScript 允许用任何值来拒绝,比如有人直接 reject("oops")
- 把这个 reason 交给你的上报函数,并标注来源为 unhandledrejection
- 可选:调用 event.preventDefault(),压掉控制台里那句 "Uncaught (in promise) ..." 的噪音,既然你已经把它上报到真正会被看到的地方
这里的 event 是一个 PromiseRejectionEvent,有两个值得知道的属性:event.promise(被拒绝的那个异步对象)和 event.reason(它被拒绝时的值——通常是 Error,但 JavaScript 允许你用任何东西来拒绝,所以别假设它一定有 .message)。调用 event.preventDefault() 会抑制浏览器自己那句 "Uncaught (in promise)" 控制台消息——既然你已经把这次拒绝上报到了别处,这样做可以避免重复记录。
还有一个配套事件 rejectionhandled,如果 .catch() 事后才出现就会触发——比如某段代码异步加载完成后才链上 .then()。它的存在是为了让你撤回已经发出的报告,因为那次拒绝其实被处理了,只是晚了一点。大多数应用永远用不到它;如果你的上报管道需要避免对时序敏感的代码产生误报,它就在那里。
它做不到的那一件事
要清楚这个监听器能给你带来什么,因为人们很容易把它当成修复方案:它不是。unhandledrejection 不会阻止结账按钮卡死,不会重试请求,也不会告诉用户出了什么问题。它做的只是让一个原本不可见的失败,出现在你真正会读到的地方。底层的 bug——那个本该存在的 .catch() 或 try/catch——仍然需要在源头修复。监听器改变的是这个 bug 在被发现之前能存活多久。
另外,上面写的只适用于浏览器:在 Node.js 里,等价写法是 process.on("unhandledRejection", (reason, promise) => { ... }),而且从 v15(2020 年)起的每个 Node 版本都更进一步——默认情况下,未处理的拒绝现在会终止进程,而不只是打一条日志。这本身就是不要对异步错误处理掉以轻心的理由。
它真正的用途
把 unhandledrejection 当成烟雾报警器:它不灭火,只保证你在房间被烟填满之前知道起火了。把它接进任何已经在收集错误的地方——Sentry、一个日志端点,甚至只是 staging 环境里一个带样式的 console group——下一次静默失败就不再静默。上面那个结账 bug 一旦有人看到,修起来并不难。那三周,是没人看到的代价。
现在就去检查:你的应用有没有全局 unhandledrejection 监听器,还是只捕获了你记得用 try/catch 包起来的那些错误?
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.