This commit is contained in:
owenchen committed 2026-10-13 10:47:39 +08:00
1 parent 6a8258ac4b
commit a7b25d49c4
29 files changed
+1960 -649

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
# 全局错误边界(未捕获异常与 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<string, number>`),记录表上限 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 的 `<uni-modal>` 是单例节点,连续两次 `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` 是唯一收口点,加上报只改这一处。