背景
在浏览器环境中,"页面崩溃(Page Crash)"并不是一个浏览器主动抛出的可监听事件。 页面可能因为以下原因被动终止:
-
内存 OOM 导致 tab 崩溃
-
浏览器内部的 renderer 进程挂掉
-
业务代码死循环、长任务阻塞导致页面卡死
-
页面在后台被系统杀进程(尤其移动端)
-
浏览器关闭 / 标签页关闭但执行不到 beforeunload(常见)
由于浏览器没有提供"页面是否异常退出"的 API,因此前端监控体系通常只能间接推断崩溃。
本次调研希望解决以下两个问题:
🎯 目标
-
如何在单标签页场景中准确推断页面是否异常退出?
-
多标签页环境中,一个页面崩溃后,如何被其他页面检测并上报?
-
是否能做到不依赖页面再次打开 ------ 即实时上报?(如 Service Worker)
-
最终方案应尽量稳定、低侵入、可扩展并减少误报率
调研方案
1. 基于「退出打标 + 心跳检测」的崩溃推断方案
这是目前业内最常见的思路,例如不少监控 SDK 都采用类似机制。
1.1 核心思路
-
页面正常退出时(beforeunload / pagehide / visibilitychange)写入 normalExit = true
-
如果是崩溃,则正常退出钩子不会触发 → normalExit 保持 false
-
下次启动页面时读取存储(localStorage),若发现上次 normalExit=false,则认为存在异常退出
单页面流程示意图
arduino
正常退出 → normalExit = true → 下次打开不告警
崩溃 → normalExit = false 且心跳断更 → 下次打开上报崩溃
// 单页面检测崩溃代码
js
// 伪代码,还需要处理beforeunload / pagehide / visibilitychange
window.addEventListener('beforeunload', () => {
localStorage.setItem('normalExit', 'true');
});
function checkCrash() {
const normalExit = localStorage.getItem('normalExit');
if (normalExit !== 'true') {
reportCrash();
}
localStorage.setItem('normalExit', 'false');
}
上面只是讨论到当页面单开的情况,那么如果是多标签页的场景下该如何设计呢?要知道localStorage是在同一个域名下各个标签页共享的。
根据上面的检测原理,会想到,给每个页面都设置一个独立的 tabId,并在其中一个页面获取所有的页面normalExit,判断normalExit 是不是 false,false则认为是发生了崩溃。
但是不行,当我们打开多页面时,因为页面并还没有退出,获取到的normalExit其实都是false的,那我们就需要多一个字段去判断,心跳时间;
1.2 多标签页场景的完整设计
针对多页面场景,每个页面需要心跳 + tabId,判断页面是否还存活
为什么需要心跳?
因为多页面时,每个页面运行中时 normalExit 本来就是 false。 所以不能只看 normalExit,需要结合"最后心跳时间":
ini
normalExit = false + 心跳超过阈值未更新 → 判定崩溃
👇 关键逻辑代码(精简示例)
** 为每个 tab 创建唯一 ID **
js
//
function getTabId() {
let id = sessionStorage.getItem('**tab_id**');
if (!id) {
id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
sessionStorage.setItem('**tab_id**', id);
}
return id;
}
心跳写入(运行中 normalExit = false)
ts
function saveAll(map: Record<string, HeartbeatRecord>) {
try {
localStorage.setItem(KEY, JSON.stringify(map));
} catch {}
}
export function writeHeartbeat(rec: Omit<HeartbeatRecord, 'ts' | 'tabId'>) {
const tabId = getTabId();
const map = loadAll();
map[tabId] = { ...map[tabId], ...rec, ts: Date.now(), tabId };
saveAll(map);
}
export function startSessionHeartbeat(intervalMs = 3000, recBase?: Omit<HeartbeatRecord, 'ts' | 'tabId'>) {
const page = `${window.location.pathname}${window.location.hash || ''}`;
let timer: number | undefined;
function beat() {
writeHeartbeat({ page, version: recBase?.version, env: recBase?.env, meta: recBase?.meta, normalExit: false });
}
beat();
timer = setInterval(beat, intervalMs);
window.addEventListener('beforeunload', () => {
markNormalExit();
if (timer) clearInterval(timer);
clearTabHeartbeat();
});
return () => {
if (timer) clearInterval(timer);
};
}
正常退出钩子(beforeunload)
js
export function markNormalExit() {
const tabId = getTabId();
const map = loadAll();
if (map[tabId]) {
map[tabId].normalExit = true;
saveAll(map);
}
}
window.addEventListener('beforeunload', () => {
markNormalExit(); // 标记正常退出
clearTabHeartbeat(); // 清除心跳
});
// 重新打开页面时判断历史页面是否崩溃
if (!rec.normalExit && diff > timeoutMs) {
reportCrash(rec);
}
下次打开时检测崩溃(核心)
判断崩溃逻辑:
-
不是正常退出时,normalExit 为 false
-
当崩溃时,normalExit 为false 或 undefined
-
diff > timeoutMs 主要是为了当多开页面时,正常的页面心跳时间一直在滚动更新,不会少于timeoutMs,防止误报
ts
export function checkPreviousAbnormalExit(
timeoutMs: number,
report: (payload) => void
) {
const now = Date.now();
const currentTab = getTabId();
const map = loadAll();
let changed = false;
Object.values(map).forEach((rec) => {
if (!rec || rec.tabId === currentTab) return;
const diff = now - (rec.ts || 0);
if (!rec.normalExit && diff > timeoutMs) {
//// diff > timeoutMs 有个弊端,当用户崩溃后,刚好设了一个时间戳,并且马上打开一个新标签页,这时diff可能还没超过timeoutMs,这种情况会漏报
report({ ...rec, diff });
delete map[rec.tabId];
changed = true;
} else if (rec.normalExit) {
delete map[rec.tabId];
changed = true;
}
});
if (changed) saveAll(map);
}
1.3 多页面心跳的隐藏坑点(必须处理)
🔥 1. 页面隐藏时定时器会被延迟
浏览器切后台后,setInterval 会被降频,甚至几秒才执行一次。 这会误伤心跳逻辑。
处理方式:
页面隐藏时直接标记 normalExit=true,避免误报。
但为什么可行?因为后台页面本身不应计入崩溃统计(用户没在看)。
🔥 2. 心跳的时间不准
如果是使用定时器更新心跳时间,心跳更新时间并不会特别准可以了解下setInterval原理,比如设了3000毫秒更新一次,有可能是3000+-N000毫秒才执行更新,也可能是主线程有大计算导致更新时间更慢,所以阈值不能设置和心跳时间一样的时间,得有一定的宽容度。 建议阈值为:
阈值 = 心跳间隔 \* 2
1.4 方案一的优劣总结
优点
-
实现简单,不依赖 Service Worker
-
能检测页面是否在上一次会话中异常退出
-
多标签页可准确判断单页崩溃
缺点(重点)
-
无法实时上报,必须等待下次打开页面
-
页面进程在后台被系统杀死,无法触发页面生命周期事件导致无法打标记,下次启动页面时会存在误报。但是误报笔者是觉得允许的,采集到样本大的页面崩溃路径才是最有可能导致崩溃的页面;
2. 基于 Service Worker 的实时心跳监控
既然方案一无法实时上报,那么是否能借助Worker 实现实时上报呢?worker 独立于页面运行,可以在页面崩溃后继续存活,从而实现实时上报。
笔者一开始是想到用 Web Worker 来实现的,但是后来发现 Web Worker 生命周期和页面是绑定的,页面崩溃后,Web Worker 也不可用,所以无法实现实时上报。只能是用service Worker 来实现。
Service Worker(SW)可以在页面崩溃后继续存活,只要浏览器进程未关闭。 利用 SW 作为"监控总控",页面与 SW 双向通信,从而实现:
✔ 页面实时心跳发送
✔ SW 主动判断某 tab 心跳超时
✔ 立即上报崩溃事件(无需等待下次进入)
2.1 方案架构图
css
页面 A/B/C
↓ (heartbeat)
Service Worker(独立线程)
↓ (report)
监控服务(如 Sentry)
2.2 SW 的核心逻辑
- 收到心跳,更新 tab 的 时间搓
js
if (data.type === 'heartbeat') {
tabLastBeat.set(tabId, { ts: now() });
ensureCrashChecker();
}
- 定时检查哪些 tab 心跳超时
js
if (nowTs - ts > CRASH_TIMEOUT_MS) {
sendReport();
tabLastBeat.delete(tabId);
}
- 页面向 SW 发送 exit 消息,避免误报
js
postToSW({ type: 'exit', tabId: this.tabId });
2.3 页面侧的心跳通知
js
this.timer = setInterval(() => {
this.postToSW({
type: 'heartbeat',
tabId: this.tabId,
ts: Date.now()
});
}, this.heartbeatIntervalMs);
同时绑定生命周期:
-
beforeunload
-
visibilitychange(hidden → exit)
-
pagehide
2.4 方案二的优劣
优点
-
支持实时上报
-
监控逻辑不依赖浏览器是否回到页面
-
多标签页信息共享更自然(SW 本来就是共享运行时)
缺点(非常关键)
-
浏览器窗口关闭时,SW 也会消失 → 无法上报
-
仅适用于开启 SW 的站点(需 HTTPS + 同源)
-
SW 更新策略复杂(需处理 skipWaiting、claim 等)
最终结论与推荐方案
| 场景 | 最佳方案 | 原因 |
|---|---|---|
| 简单不复杂 | 方案一(localStorage + 心跳) | 下次打开可判断所有异常退出,覆盖范围最大 |
| 想实时上报崩溃(非浏览器关闭情况) | 方案二(Service Worker) | 页面崩溃后 SW 仍可运行并上报 |
| 希望误报率最低 | 结合两者 | SW 实时 & 下次打开兜底 |
附录
- 完整方案一代码示例见:页面崩溃上报实现代码
- 方案二 Service Worker 示例代码见:Service Worker 页面崩溃监控代码,Service Worker 代码
- 完整demo 见:页面崩溃监控 Demo
如果觉得代码有用麻烦点个小星星。