# 全局错误边界(未捕获异常与 Promise 拒绝的兜底) 适用范围:`src/common/global-error.ts`、`src/App.vue` 的 `setupGlobalErrorBoundary()` 调用、`src/common/http.interceptor.ts` 的"已提示"标记。 本文规定"什么错误由谁提示用户",避免同一个故障弹两遍、或者干脆一声不响。请求层本身的链路见 [page-routing.md](./page-routing.md),地址取值见 [environment.md](./environment.md)。 --- ## 1. 为什么必须有 uni-app 没有 vue-router 那种 `router.onError`,也没有全局 `app.config.errorHandler` 的成熟落点: - 页面里一个没 `catch` 的 promise,只会在控制台留一行,用户看到的是"点了没反应"; - 运行时抛错若发生在异步回调里,可能连错误页都进不去。 所以本项目的兜底是 **App 级两个钩子 + 一个上报模块**:保证"控制台一定有记录,用户按需得到一次轻提示"。 ## 2. 数据流与职责 ```text 页面 / service 漏 catch -> 错误来源(按端不同,见 §2.1) uni App 钩子:onError(err) / onUnhandledRejection({ reason, promise }) H5 额外兜底:window "error" / "unhandledrejection" 监听 -> setupGlobalErrorBoundary()(src/common/global-error.ts,在 App.vue setup 顶层调用一次) -> reportGlobalError(error, source) 1) console.error 记全量原始错误(永不省略) 2) 带"已提示"标记 -> 到此为止,只留日志 3) 文案在 3s 内已提示过 -> 静默(去重) 4) DEV -> 排队弹 uni.showModal,显示来源与详情前 300 字 PROD -> uni.showToast 一句通用文案 ``` | 位置 | 负责什么 | | --- | --- | | `App.vue` | 只在 setup 顶层调一次 `setupGlobalErrorBoundary()`,不写判断逻辑 | | `common/global-error.ts` | 钩子注册、H5 window 兜底、文案提取、去重、弹窗排队 | | `common/http.interceptor.ts` | 决定"这一处是否已经打扰过用户",并给错误打标记 | | 页面 / 业务代码 | 能处理就自己 `catch` 并处理;处理不了就别 `catch`,交给边界 | 钩子注册必须放在 **App.vue 的 setup 顶层**:`onError` / `onUnhandledRejection` 要把回调绑到 App 实例上,放进 `onLaunch` 的回调里就离开了 setup 上下文。 `onUnhandledRejection` 只把 `event.reason` 传下去(不是整个 event):类型里 `reason` 声明为 `string`,真机常常是 `Error` 对象,传原物才能让 `describe()` 拿到 `message`;`describe()` 也支持再往里挖一层(认 `reason` 字段),所以哪怕误传整个事件对象也不会掉进 `[object Object]`。 ### 2.1 平台派发差异(H5 实测,不是抄文档) | 端 | 谁派发 uni 钩子 | 够不着的错误 | 本项目的处理 | | --- | --- | --- | --- | | H5 | `onError` **只由 Vue 的 `app.config.errorHandler` 触发**(组件渲染 / 生命周期里的同步错误);`onUnhandledRejection` 在 `uni-h5` 全文没有派发点(搜 `unhandledrejection` 命中 0) | `setTimeout` / 事件回调里抛出的异常、所有 Promise 拒绝 | 额外挂 `window` 的 `error` / `unhandledrejection` 监听 | | 微信小程序 | 由小程序运行时派发 `App.onError` / `App.onUnhandledRejection`(**本轮未在真机/开发者工具实测**) | 需 capture 阶段才收得到的资源加载错误(未接) | uni 钩子覆盖主链路,§8 列了待办 | | App | 由 uni-app 运行时派发(**同样未实测**) | 同左 | uni 钩子覆盖主链路 | 第一轮实测的教训就在这里:只在 `App.vue` 里注册 uni 钩子时,浏览器控制台能看到裸的 `边界探针-error`,但 `[global:*]` 一条都没出现、弹窗一次没弹 —— **钩子注册成功≠会被触发**。所以 H5 的 window 兜底是必需项,`setupGlobalErrorBoundary()` 用 `typeof window` 判断只在浏览器里挂,小程序 / App 端自动跳过,`windowFallbackInstalled` 保证 HMR 重复执行也不会叠监听。 ## 3. 三条纪律 1. **已提示过的不再打扰。** 请求层已经弹过 toast/modal、或已经跳到登录页/403 页的错误,用 `markErrorNotified(err)` 打标记,边界见到标记只写日志。 2. **同一文案 3 秒内只提示一次。** 去重按文案分桶(`lastShownAt: Map`),记录表上限 64 条,超限时清掉超过 30 秒的旧项,长时间运行不会无限增长。 3. **只记录与提示,绝不吞错误。** 该 `throw` 的照旧 `throw`,边界不负责"让程序继续跑"。否则故障会被藏进日志,排查时连"到底报没报"都区分不出来。 `markErrorNotified` 原样返回入参,所以能内联使用: ```ts import { markErrorNotified } from "@/common/global-error"; throw markErrorNotified(new Error(bizMsg)); reject(markErrorNotified(err)); ``` ## 4. 请求层哪些站点打了标记 `describe()` 依次尝试 `message` / `errMsg` / `errorMessage` / `msg` / `Message` 取值,取不到就 `String(error)` 兜底,因此字符串型错误也能出文案。 | 站点 | 是否标记 | 原因 | | --- | --- | --- | | `Failed to connect` / `request:fail` | 仅当 `meta.toast` 打开 | 提示由 `meta.toast` 决定;关掉提示时把兜底交还给边界 | | 401 无登录态 → 跳登录页 | 标记 | 页面已经跳走,等于已告知 | | 刷新令牌失败 → `goLogin` | 标记原错误对象 | 保留 `status` 供调用方分支,只补标记 | | 403 → 跳 403 页 | 标记 | 403 页会列出缺哪条权限,不需要再弹一次 | | 非 2xx 通用分支 | 仅当 `meta.toast` 打开 | `err.status` / `err.data` 保持不变 | | 业务异常(`ValidatedException` / `ServiceErrorException`) | 标记 | `detectBusinessException` 内部已经 modal/toast | | `uploadFile` 状态码异常 / 网络失败 / 业务异常 | 标记 | 三处都先 `notifyError` 再 reject | | `downloadFile` JSON 错误体 / 非 2xx | 标记 | 已提示 | | `downloadFile` 的 `writeFile` / `openDocument` 失败 | **不标记** | 这两处原本静默失败,正是要靠边界补提示 | | `downloadFile` 内部 `uni.request` 网络失败 | **不标记** | 没有任何提示动作 | ## 5. 业务代码怎么写 - **想让用户看到失败**:直接 `await`,别 `catch`。边界会记日志并在生产给一句轻提示。 - **自己有能力恢复**(重试、占位、表单标红):自己 `catch`,处理完就别再抛;此时边界看不到这个错误,符合预期。 - **既要处理又要留痕**:`catch` 里 `console.error` 后按需 `markErrorNotified(err)`,防止上层再抛时重复提示。 - **禁止** `try { ... } catch { }` 空吞。确实要忽略的错误,写成 `catch { /* 说明为什么可以忽略 */ }` 并在注释里给出理由。 ## 6. 两端表现差异 | 场景 | DEV(`import.meta.env.DEV`) | 生产构建 | | --- | --- | --- | | 未捕获错误 | `showModal`:标题带来源(`error` / `rejection`),正文是错误文案前 300 字 | `showToast`:"操作未成功,请稍后重试" | | 已提示过的错误 | 只有 `console.error` | 只有 `console.error` | | 同文案连续报错 | 首条弹窗,其余 3 秒内静默 | 首条 toast,其余 3 秒内静默 | 开发期弹窗是刻意的:"看到了但没提示"和"确实没报错"必须能一眼区分。生产提示不带内部术语与堆栈,详情只在控制台。 **弹窗必须排队**:H5 的 `` 是单例节点,连续两次 `showModal` 会原地改写同一个框,用户只看得见最后一条(实测过)。`showDevModal()` 因此按时间槽排队——每条占用最多 `MODAL_MAX_HOLD_MS = 5000ms`,到点没点"确定"也放行下一条,既不丢展示也不会把后续错误堵死。 ## 7. 验证记录与配方 ### 7.1 已完成的 H5 实测(2026-10-13,登录页临时探针) 探针:`onLoad` 里 600ms 抛 `new Error("边界探针-error")`、900ms 产生 `Promise.reject(new Error("边界探针-rejection"))`。验证完已删除。 | 检查项 | 修复前 | 修复后实测 | | --- | --- | --- | | `[global:error]` 日志 | 无 | 1 条(window `error` 监听) | | `[global:rejection]` 日志 | 无 | 1 条(window `unhandledrejection` 监听) | | 弹窗标题 | 一次都没弹 | `未捕获错误(error)` → `未捕获错误(rejection)` | | 排队时刻 | — | 第二条在 t≈5.6s 出现(第一条 t≈0.6s + 5s 时间槽),实测与 `MODAL_MAX_HOLD_MS` 一致 | | 重复提示 | — | 无:uni 钩子与 window 监听没有叠加打两遍,`uni-modal` 节点数恒为 1,无多余 toast | ### 7.2 复现配方 1. 任意页面的 `onLoad` 里临时加一句 `setTimeout(() => { throw new Error("边界探针"); }, 600);` 与一句 `setTimeout(() => { Promise.reject(new Error("边界探针-rejection")); }, 900);`; 2. H5 跑 `npm run dev`,**强制整页刷新**(env / App.vue 改动不走热更):应看到两条 `[global:*]` 日志与两个先后出现的弹窗; 3. 换成发一个必然失败的请求:应只有请求层那一次提示,边界只留日志(说明"已提示"标记生效); 4. 小程序 / App 端:用微信开发者工具与 HBuilderX 各跑一次同样两个探针,确认 `[global:*]` 日志是否出现 —— 这一步还没做过,结果请回填 §8。 `npm run type-check` 与 `npm run lint` 只能证明代码可编译,**不能证明钩子被触发**;改完这块至少跑一遍第 1、2 步。 ## 8. 已知缺口 - **小程序 / App 端运行时未实测**:H5 已钉死(§7.1),另两端只做到"构建产物里含边界代码"。§7.2 第 4 步跑完前,不能宣称三端都兜住了。 - **资源加载错误没接**:`img` / `script` 404 的 `error` 事件不冒泡,需要 `window.addEventListener("error", handler, true)`(capture 阶段)才收得到,目前未挂。 - **页面级错误未接管**:`onError` 是 App 级的,某个页面自己用 `errorCaptured` 拦截后就不会冒到这里(目前项目里没人用 `errorCaptured`)。 - **提示文案未国际化**:`"操作未成功,请稍后重试"` 与弹窗标题都是硬编码中文,接多语言时应改走 `vue-i18n`,与 `src/locale/lang` 一起维护。 - **生产 toast 文案过于笼统**:刻意不带细节,但如果要做错误上报(按 `source` + 文案指纹聚合),`reportGlobalError` 是唯一收口点,加上报只改这一处。