Files
Docker/uniapp/youjiala/src/md/error-handling.md
T
2026-10-13 10:47:39 +08:00

10 KiB
Raw Blame History

全局错误边界(未捕获异常与 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. 三条纪律

  1. 已提示过的不再打扰。 请求层已经弹过 toast/modal、或已经跳到登录页/403 页的错误,用 markErrorNotified(err) 打标记,边界见到标记只写日志。
  2. 同一文案 3 秒内只提示一次。 去重按文案分桶(lastShownAt: Map<string, number>),记录表上限 64 条,超限时清掉超过 30 秒的旧项,长时间运行不会无限增长。
  3. 只记录与提示,绝不吞错误。 该 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 复现配方

  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 是唯一收口点,加上报只改这一处。