Files
Docker/uniapp/youjiala/src/common/global-error.ts
T
2026-10-13 10:47:39 +08:00

209 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全局错误边界:兜住"没人处理的运行时错误与 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");
});
}