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

+208
View File
@@ -0,0 +1,208 @@
/**
* 全局错误边界:兜住"没人处理的运行时错误与 Promise 拒绝"
*
* 为什么必须有:uni-app 没有 vue-router 的 errorHandler,页面里一个未 catch 的
* promise 只会在控制台留一行日志,用户看到的是"点了没反应"或白屏。
* 本模块负责把这类异常变成:控制台一定有记录 + 用户按需得到一次轻提示。
*
* 三条纪律(详见 src/md/error-handling.md):
* 1) 请求层已经提示过的错误不再打扰用户——用 markErrorNotified 打标记,这里认标记;
* 2) 同一文案节流去重,避免循环里报错把屏幕刷满弹窗;
* 3) 只记录与提示,绝不吞掉错误:该抛的仍然抛,防止"边界把故障藏起来"。
*
* 平台差异(2026-10-13 用 H5 真跑一遍测出来的,不是抄文档):
* uni-h5 的 ON_ERROR 只在 Vue 的 app.config.errorHandler 里派发,组件同步错误才走得到
* App.onError;setTimeout / 事件回调里的异常够不着它,而 onUnhandledRejection 在 uni-h5
* 全文没有任何派发点(搜 unhandledrejection 命中 0)。所以 H5 必须自己挂 window 监听兜底,
* 否则"全局错误边界"只是看着挂了、实际从不触发。口径见 src/md/error-handling.md。
*/
import { onError, onUnhandledRejection } from "@dcloudio/uni-app";
/** 已在别处提示过的标记键:请求层弹过 toast/modal 或已跳错误页时置位 */
const NOTIFIED_MARK = "__youjialaErrorNotified";
/** 同类文案的重复提示间隔(毫秒) */
const THROTTLE_MS = 3000;
/** 记录表容量上限,超过即清理过期项,防止长时间运行后无限增长 */
const TRACK_MAX = 64;
/** 上一次提示的时间戳,按文案分桶 */
const lastShownAt = new Map<string, number>();
/**
* 标记"这个错误已经提示过用户",全局边界收到它就只记日志。
* 供请求层调用:它已经弹过 modal / 已经跳了 401、403 页,再提示一次就是打扰。
* 原样返回入参,方便在 `throw markErrorNotified(err)` / `reject(markErrorNotified(err))` 处内联。
*/
export function markErrorNotified<T>(error: T): T {
if (error && (typeof error === "object" || typeof error === "function")) {
(error as Record<string, unknown>)[NOTIFIED_MARK] = true;
}
return error;
}
/** 是否已被别处提示过 */
function isNotified(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
return (error as Record<string, unknown>)[NOTIFIED_MARK] === true;
}
/** 从错误对象里依次尝试取值可读文案的字段名 */
const MESSAGE_KEYS = [
"message",
"errMsg",
"errorMessage",
"msg",
"Message",
// 兜底:万一有调用方把整个事件对象丢进来(window / uni 的拒绝事件都带 reason)
"reason",
] as const;
/** 从任意形态的错误里抠出一行可读文案(Error / 字符串 / uni 回调 / 后端包装) */
function describe(error: unknown, depth = 0): string {
if (!error) {
return "";
}
if (typeof error === "string") {
return error;
}
// 非对象、或已经在往里挖第二层:直接字符串化(Error 会得到 "Error: 文案")
if (typeof error !== "object" || depth > 1) {
return String(error);
}
const raw = error as Record<string, unknown>;
for (const key of MESSAGE_KEYS) {
const value = raw[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
// 字段自己是个错误对象(window / uni 的 reason 常是 Error):只再挖一层,防止成环
if (depth === 0 && value && typeof value === "object") {
const nested = describe(value, 1);
if (nested) {
return nested;
}
}
}
return String(error);
}
/** 同一文案在 THROTTLE_MS 内只允许提示一次 */
function shouldNotify(text: string): boolean {
const now = Date.now();
const previous = lastShownAt.get(text) ?? 0;
if (now - previous < THROTTLE_MS) {
return false;
}
if (lastShownAt.size >= TRACK_MAX) {
lastShownAt.forEach((stamped, key) => {
if (now - stamped > THROTTLE_MS * 10) {
lastShownAt.delete(key);
}
});
}
lastShownAt.set(text, now);
return true;
}
/** 错误来源:运行时异常 / 未处理的 Promise 拒绝 */
export type GlobalErrorSource = "error" | "rejection";
/** 开发期弹窗最长占用时间:过了这个点就允许下一条弹窗顶上 */
const MODAL_MAX_HOLD_MS = 5000;
/** 下一条开发期弹窗可以出现的最早时刻(Date.now() 基准) */
let modalFreeAt = 0;
/**
* 开发期弹窗:uni 的 showModal 在 H5 是单例,后一次调用会直接改写前一次的标题与正文,
* 连着来两条错误时用户只会看到最后一条。这里按时间槽排队,让每条都被看得见;
* 到点没确认也放行(最多晚 MODAL_MAX_HOLD_MS 秒),不让弹窗把后续错误堵死。
*/
function showDevModal(title: string, content: string): void {
const now = Date.now();
const wait = Math.max(0, modalFreeAt - now);
modalFreeAt = now + wait + MODAL_MAX_HOLD_MS;
setTimeout(() => {
uni.showModal({
title,
content,
showCancel: false,
});
}, wait);
}
/**
* 统一入口:记日志 -> 判断要不要提示。
* source 只用于日志前缀与开发期弹窗标题,生产提示里不带它:用户不需要内部术语。
*/
export function reportGlobalError(
error: unknown,
source: GlobalErrorSource,
): void {
const text = describe(error);
// 全局边界的职责就是留痕:console.error 在 no-console 配置里本就放行,无需 disable 注释
console.error(`[global:${source}]`, error);
if (isNotified(error)) {
return;
}
if (!text || !shouldNotify(text)) {
return;
}
// 开发期把详情摊开:这时"看到了但没提示"和"确实没报错"必须能一眼区分
if (import.meta.env.DEV) {
showDevModal(`未捕获错误(${source})`, text.slice(0, 300));
return;
}
// 生产期只给一次轻提示:详情已进 console,用户不需要看堆栈
uni.showToast({
title: "操作未成功,请稍后重试",
icon: "none",
duration: 2000,
});
}
/** window 兜底只挂一次:H5 的 HMR / 多次挂载不该叠出多份监听 */
let windowFallbackInstalled = false;
/** 只有浏览器环境才有 window:小程序与 App 的逻辑层拿不到,返回 undefined 即跳过 */
function browserWindow(): Window | undefined {
if (typeof window === "undefined") {
return undefined;
}
return typeof window.addEventListener === "function" ? window : undefined;
}
/**
* 挂载全局错误边界,在 App.vue 的 setup 顶层调用一次(钩子必须注册在 App 实例上,
* 放进 onLaunch 回调里就离开了 setup 上下文)。
*
* 为什么要额外挂 window:uni-h5 的 ON_ERROR 只由 Vue 的 errorHandler 触发,
* onUnhandledRejection 在 uni-h5 里根本没有派发点(实测:搜 unhandledrejection 命中 0),
* 所以 setTimeout / 事件回调里的异常与所有 Promise 拒绝在 H5 上都到不了 uni 钩子,
* 必须由 window 兜底;小程序 / App 端由各自运行时派发 uni 钩子,这段自动跳过。
*/
export function setupGlobalErrorBoundary(): void {
onError((error) => {
reportGlobalError(error, "error");
});
onUnhandledRejection((event) => {
reportGlobalError(event?.reason, "rejection");
});
const target = browserWindow();
if (!target || windowFallbackInstalled) {
return;
}
windowFallbackInstalled = true;
target.addEventListener("error", (event) => {
// 资源加载失败(img/script 404)时 error 为 null,只有 message:照常留痕
reportGlobalError(event.error ?? event.message, "error");
});
target.addEventListener("unhandledrejection", (event) => {
reportGlobalError(event.reason, "rejection");
});
}