209 lines
7.8 KiB
TypeScript
209 lines
7.8 KiB
TypeScript
/**
|
||
* 全局错误边界:兜住"没人处理的运行时错误与 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");
|
||
});
|
||
}
|