本节摘要:异步错误不沿调用栈展开——抛出它的栈帧早已弹出,rejection 沿 Promise 链漂移寻找最近的处理器,没人接就以 unhandledrejection 的形式浮到全局。本节讲清漂移通道、三层防线(局部 try-catch、链尾 catch、全局兜底)、fetch 场景的超时与取消与 HTTP 错误的统一处理,最后搭一个最小可用的前端错误上报骨架。
先看「追不上」的现场:
function loadConfig() { return new Promise((resolve, reject) => { setTimeout(() => reject(new Error('配置加载失败')), 100); }); } function boot() { loadConfig(); // 忘了 return 也忘了 catch console.log('boot 继续跑'); } try { boot(); } catch (e) { console.log('永远到不了这里'); }
实测:boot 正常跑完,「配置加载失败」既没进 catch,也没打断任何同步流程——它在 100 毫秒后以 unhandledrejection 的形式出现在控制台。原因在栈:reject 发生时,boot 的栈帧早就弹了,同步的 try-catch 根本罩不住另一个时间点发生的错误。异步错误的传播通道是 Promise 链(第 4 章),不是调用栈(第 7.1 节)。
三种正确接法对照:
// 接法一:async 里 await 加 try-catch(第4.3节的工程红利) async function boot1() { try { const cfg = await loadConfig(); console.log('拿到配置', cfg); } catch (e) { console.log('接住:', e.message); // 接住: 配置加载失败 } } // 接法二:链尾 catch(第4.2节的错误穿透) function boot2() { return loadConfig() .then(cfg => console.log('拿到配置', cfg)) .catch(e => console.log('接住:', e.message)); } // 接法三:不接,但标记「我有意忽略」 loadConfig().catch(e => console.warn('配置降级运行:', e.message));
接法三的场景是「失败可容忍」——非关键资源失败时降级而不是中断。关键在于必须显式写 catch:一个空白的 catch 表达意图,比放任漂移成 unhandledrejection 强一个量级。
局部接不住的,交给全局兜底:
// 防线一:同步错误与资源加载错误 window.addEventListener('error', (e) => { if (e.target && e.target !== window) { report({ kind: 'resource-failed', tag: e.target.tagName, url: e.target.src || '' }); return; // 资源加载失败(img script link)走捕获位监听,冒泡版拿不到细节 } report({ kind: 'js-error', message: e.message, source: e.filename, line: e.lineno, col: e.colno, stack: e.error && e.error.stack }); }, true); // 捕获位:资源错误不冒泡(第5.2节的巡逻规则) // 防线二:无人处理的 rejection window.addEventListener('unhandledrejection', (e) => { report({ kind: 'unhandled-rejection', reason: String(e.reason && e.reason.message || e.reason) }); e.preventDefault(); // 阻止控制台再刷一遍红字(按需) }); function report(payload) { console.log('上报', payload); // 真实实现:navigator.sendBeacon 上报到收集端(页面卸载也不丢) }
两道防线的分工清晰:error 事件接同步异常与资源加载失败(要挂捕获位),unhandledrejection 接漂移到底的 rejection。上报通道首选 navigator.sendBeacon——异步、不阻塞卸载、浏览器保证送达;需要带响应体的场景才用 fetch keepalive。
配套的还有监听未捕获错误的兜底行为差异:没有全局监听时,未处理 rejection 只是控制台警告(新版本浏览器会打印但不停页面);同步未捕获异常会中断当前任务,但事件循环照常——单个任务的死亡不会杀死整个页面,下一个宏任务照跑。这既是容错性也是迷惑性:错误的影响范围要看清。
网络场景把三类错误揉在一起:网络层失败(fetch reject)、HTTP 状态错误(fetch 正常返回)、业务数据错误(自己校验抛出)。统一封装:
async function request(url, options = {}) { const { timeout = 8000, ...rest } = options; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { ...rest, signal: controller.signal }); if (!res.ok) { throw new ApiError(`请求失败 ${res.status}`, { status: res.status, retryable: res.status >= 500 }); } const data = await res.json(); // 解析失败也会抛 SyntaxError,一并进 catch if (data.code !== 0) { throw new ApiError(data.message || '业务错误', { status: res.status, retryable: false }); } return data; } catch (e) { if (e.name === 'AbortError') throw new ApiError('请求超时', { status: 0, retryable: true }); throw e; // 其余原样上抛,交给调用方或全局兜底 } finally { clearTimeout(timer); } }
封装要点:超时用 AbortController 竞速(第 5.4 节的模式);AbortError 转译成带 retryable 标记的业务错误(第 7.1 节的自定义类型在此变现);finally 清定时器(第 7.1 节细则一的落地)。调用方拿到的一律是 ApiError,分流逻辑统一写在 catch 里。
可重试错误的退避重试也顺理成章:
async function withRetry(fn, times = 3) { for (let i = 0; ; i++) { try { return await fn(); } catch (e) { if (!(e.retryable) || i >= times) throw e; await new Promise(r => setTimeout(r, 300 * 2 ** i)); // 指数退避:300 600 1200 } } }
异步错误处理里最阴的一类不是「没接住」,是「接晚了」:
let latestId = 0; async function search(keyword) { const id = ++latestId; // 登记本次请求的序号 const result = await request(`搜索接口?kw=${encodeURIComponent(keyword)}`); if (id !== latestId) return null; // 已经有更新的请求,本次结果作废 return result; } // 用户连打三个字:三次请求并发,只有最后一次的结果有资格渲染
过期响应不是错误却比错误更破坏状态——旧结果晚到会覆盖新数据。序号守卫与 AbortController 取消(新请求出发时 abort 旧的)是两种正解,前者简单、后者省流量。这类「时序正确性」问题在错误处理清单上应占一席,因为它同样源自异步的通道特性:结果回来的时刻,世界已经变了。
最后把三层防线与各章知识的对应收拢成表:
| 层级 | 机制 | 出自 |
|---|---|---|
| 局部 try-catch | await 处就地恢复 | 第 4.3 节 |
| 链尾 catch / 显式降级 | 错误穿透到统一处理点 | 第 4.2 节 |
| 全局 error 捕获位 | 同步异常与资源失败 | 第 5.2 节 |
| 全局 unhandledrejection | 漂移到底的 rejection | 本节 |
| 上报通道 sendBeacon | 卸载不丢的最后一程 | 本节 |
| 超时取消与竞态守卫 | 时序正确性 | 第 5.4 节与本节 |
⚠️ 常见坑:unhandledrejection 里不调用 preventDefault 时,部分环境(老版 Node)会直接进程退出——Node 15 起未处理 rejection 默认致命。浏览器只是警告,但「写库场景下漂移的 rejection」在 Node 侧是实打实的崩溃源,服务端代码对每个 Promise 都要问一句「谁接」。
把两道防线、sendBeacon、采样与脱敏拼成一个五十行内的上报器骨架,可直接作为项目起点:
const reporter = (() => { const endpoint = '收集服务地址'; const queue = []; let sampleRate = 1; // 全量采样,按量级调整 function send(payload) { const body = JSON.stringify({ ...payload, ua: navigator.userAgent, page: location.pathname, t: Date.now() }); if (navigator.sendBeacon) { navigator.sendBeacon(endpoint, body); // 卸载也保证送达 } else { fetch(endpoint, { method: 'POST', body, keepalive: true }); } } function report(kind, detail) { if (Math.random() > sampleRate) return; // 采样:量级大时降成本 const safe = sanitize(detail); // 脱敏后再出门 queue.push({ kind, ...safe }); if (queue.length >= 10) flush(); } function sanitize(detail) { if (!detail || typeof detail !== 'object') return { detail: String(detail) }; const out = {}; for (const [k, v] of Object.entries(detail)) { if (/token|password|secret/i.test(k)) out[k] = '[已脱敏]'; else out[k] = typeof v === 'string' ? v.slice(0, 500) : v; // 截断超长字段 } return out; } function flush() { while (queue.length) send(queue.shift()); } // 两道全局防线 window.addEventListener('error', (e) => { if (e.target && e.target !== window) { report('resource', { tag: e.target.tagName }); } else { report('js', { message: e.message, stack: e.error && e.error.stack }); } }, true); window.addEventListener('unhandledrejection', (e) => { report('rejection', { reason: String(e.reason && e.reason.message || e.reason) }); e.preventDefault(); }); // 卸载前最后一冲 window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(); }); return { report, flush }; })();
骨架里的五个决策各有出处:sendBeacon 优先是「卸载不丢」(本节);采样率是成本阀门;脱敏在客户端先做一道(令牌与密码字段拦下,超长截断防打爆收集端);批量攒十条一冲降低请求数;visibilitychange 的 hidden 时机做最后冲刷——移动端页面销毁前最可靠的钩子。生产化方向:补「页面会话 ID」串起同一访问的错误、错误指纹去重(同栈只报一次计数)、以及「最近操作 breadcrumbs」(出错前用户干了什么的短列表,排错效率倍增)。
unhandledrejection 里能把错误「恢复」掉吗?
不能真正恢复——Promise 的落定不可逆,事件里拿不到「重跑then链」的通道。能做的是补救:触发降级逻辑、打点上报、preventDefault 压掉控制台噪声。真正的恢复必须发生在链上(局部 catch 或统一封装),全局兜底的定位是「最后防线与情报站」,不是补救执行者。
sendBeacon 和 fetch keepalive 怎么选?
默认 sendBeacon:专为卸载场景设计、浏览器排队保证送达、不占主线程。它的限制是「只能 POST 小体积数据、不知道服务端结果」——需要响应确认或大数据体的场景用 fetch keepalive。两者都失败的老浏览器,退路是同步小请求(仅卸载时刻值得牺牲体验)。
错误该在哪层吞、在哪层抛?
判断口诀:「这层能不能给出比上层更好的处理」。能(比如重试、降级、默认值)就接住处理;不能就带着上下文往上抛,让有能力的一方决策;到顶层还处理不了就兜底上报并给用户一个可理解的状态。全链路谁都不接是事故,层层都接是噪声——两条都要在评审时拦下来。
错误处理的最后一公里是「给人看」。技术错误与业务错误要分开呈现,四个通道按严重度递进:
| 通道 | 适用 | 典型场景 | 注意 |
|---|---|---|---|
| 静默加日志 | 用户无感知可自愈 | 后台预取失败、统计上报失败 | 必须有重试或放弃的明确决策 |
| 内联提示 | 影响局部操作 | 表单字段校验失败、单条数据加载失败 | 就地标注,不打断其他区域 |
| 轻提示 | 瞬时操作失败 | 保存失败可重试、网络抖动 | 自动消失但要留重试入口 |
| 全局横幅 | 影响整页能力 | 登录态失效、服务不可用 | 明确动作导向(重登、刷新) |
两条设计纪律:降级优先于报错——图表数据拉不到,显示「暂无数据」加重试按钮,比红色错误框友好且同样诚实;一次只呈现一个焦点——级联失败(一个接口挂带动十个组件全弹错)必须在上层收敛成一个横幅,否则用户被十个 toast 轰炸。收敛的实现正是第 7.1 节的错误类型设计:让「同源错误」可识别、可合并。
测试错误路径也有章法:单元测试里用假接口注入各类失败(超时、五百、业务码非零),断言降级行为而非只断言快乐路径;集成环境跑「杀接口」演练(代理工具断网模拟),验证全局兜底真的兜得住。错误处理是少数「写得越多、线上越稳」的代码——前提是每层各司其职,而不是层层设防。
上报器把数据送出门之后,真正的价值要靠三个指标维度把它盘活。
维度一:错误率。 每千次会话的错误数(按错误指纹分组)。单看绝对数会随流量波动,比率才可比。健康基线建立后,错误率突增就是「新版本引入问题」的第一信号——配合版本维度切分,一分钟内定位到肇事版本。
维度二:影响面。 独立用户数与页面占比。一个错误每天触发一万次但集中在同一台刷脚本的机器上,优先级远低于每天触发两百次但散布在两百个真实用户的错误。按用户去重的统计口径要在上报设计时就定好(会话标识的生成与上报)。
维度三:恢复与趋势。 错误首次出现的时间、随时间的消长曲线。回滚或热修后曲线应回落——不回落说明修错了对象。长期缓慢爬升的错误率(每版本涨一点)则指向累积性的技术债。
三个维度的最小实现都不复杂:指纹(错误消息加栈首行的哈希)分组、会话标识去重、版本字段切分——前两样本节上报器里已有位置,第三个是发布流程带出的元数据。指标化的意义在于把「感觉最近错误变多了」变成「错误率从千分之三涨到千分之七,始于三天前的发版」——前者引发焦虑,后者引发行动。
全书的错误知识,最后怎么串成一张图?
沿「错误的旅程」串:诞生(抛出点:第 1 章的类型错误、第 2 章的爆栈、第 3 章的链上未命中)→ 同步传播(第 7.1 节:沿栈逆向展开,finally 沿途收尾)→ 异步传播(第 4 章与本章:沿 Promise 链漂移,catch 拦截,rejection 穿透)→ 兜底(本章:两道全局防线)→ 上报与指标化(本节)。五个站点各有机制、各有工具、各有规约,但服务同一个目标:让失败像成功一样被设计过。这也是全书「引擎现场」主调在错误主题上的落点——把每一次报错都当成引擎在向你直播它的内部状态。
错误的一生讲完了:局部接法、链尾穿透、全局兜底、上报骨架、用户呈现策略与指标化看板,从代码到产品走通了一条完整的容错链路。最后一节转向代码的「组织与装载」——模块体系如何决定依赖的时序与隔离,这也是全书引擎现场的最后一块拼图。