10 KiB
全局错误边界(未捕获异常与 Promise 拒绝的兜底)
适用范围:src/common/global-error.ts、src/App.vue 的 setupGlobalErrorBoundary() 调用、src/common/http.interceptor.ts 的"已提示"标记。
本文规定"什么错误由谁提示用户",避免同一个故障弹两遍、或者干脆一声不响。请求层本身的链路见 page-routing.md,地址取值见 environment.md。
1. 为什么必须有
uni-app 没有 vue-router 那种 router.onError,也没有全局 app.config.errorHandler 的成熟落点:
- 页面里一个没
catch的 promise,只会在控制台留一行,用户看到的是"点了没反应"; - 运行时抛错若发生在异步回调里,可能连错误页都进不去。
所以本项目的兜底是 App 级两个钩子 + 一个上报模块:保证"控制台一定有记录,用户按需得到一次轻提示"。
2. 数据流与职责
页面 / 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. 三条纪律
- 已提示过的不再打扰。 请求层已经弹过 toast/modal、或已经跳到登录页/403 页的错误,用
markErrorNotified(err)打标记,边界见到标记只写日志。 - 同一文案 3 秒内只提示一次。 去重按文案分桶(
lastShownAt: Map<string, number>),记录表上限 64 条,超限时清掉超过 30 秒的旧项,长时间运行不会无限增长。 - 只记录与提示,绝不吞错误。 该
throw的照旧throw,边界不负责"让程序继续跑"。否则故障会被藏进日志,排查时连"到底报没报"都区分不出来。
markErrorNotified 原样返回入参,所以能内联使用:
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 复现配方
- 任意页面的
onLoad里临时加一句setTimeout(() => { throw new Error("边界探针"); }, 600);与一句setTimeout(() => { Promise.reject(new Error("边界探针-rejection")); }, 900);; - H5 跑
npm run dev,强制整页刷新(env / App.vue 改动不走热更):应看到两条[global:*]日志与两个先后出现的弹窗; - 换成发一个必然失败的请求:应只有请求层那一次提示,边界只留日志(说明"已提示"标记生效);
- 小程序 / App 端:用微信开发者工具与 HBuilderX 各跑一次同样两个探针,确认
[global:*]日志是否出现 —— 这一步还没做过,结果请回填 §8。
npm run type-check 与 npm run lint 只能证明代码可编译,不能证明钩子被触发;改完这块至少跑一遍第 1、2 步。
8. 已知缺口
- 小程序 / App 端运行时未实测:H5 已钉死(§7.1),另两端只做到"构建产物里含边界代码"。§7.2 第 4 步跑完前,不能宣称三端都兜住了。
- 资源加载错误没接:
img/script404 的error事件不冒泡,需要window.addEventListener("error", handler, true)(capture 阶段)才收得到,目前未挂。 - 页面级错误未接管:
onError是 App 级的,某个页面自己用errorCaptured拦截后就不会冒到这里(目前项目里没人用errorCaptured)。 - 提示文案未国际化:
"操作未成功,请稍后重试"与弹窗标题都是硬编码中文,接多语言时应改走vue-i18n,与src/locale/lang一起维护。 - 生产 toast 文案过于笼统:刻意不带细节,但如果要做错误上报(按
source+ 文案指纹聚合),reportGlobalError是唯一收口点,加上报只改这一处。