线上出问题最难受的不是修,是不知道。用户截图发过来,只有一个"白屏了",连报错都没有。Sentry 这类服务很好用,但小项目上 SaaS 有点重——而且报错数据其实挺敏感的。
自己搭一套的成本比想象中低。核心就三件事:捕获、上报、还原。
捕获:四个入口,一个都不能少
// monitor.ts
type Report = {
type: 'error' | 'unhandledrejection' | 'resource' | 'blank';
message: string;
stack?: string;
url: string;
ua: string;
time: number;
extra?: Record<string, unknown>;
};
const queue: Report[] = [];
const seen = new Map<string, number>();
const SAMPLE_RATE = 0.3;
function push(report: Report) {
const key = `${report.type}|${report.message}|${report.stack?.split('\n')[1] || ''}`;
const count = (seen.get(key) || 0) + 1;
seen.set(key, count);
// 同一错误只上报前 3 次,避免死循环刷爆接口
if (count > 3) return;
if (Math.random() > SAMPLE_RATE) return;
queue.push({ ...report, extra: { ...report.extra, count } });
if (queue.length >= 10) flush();
}
去重和采样一定要在客户端做。不然一个在 requestAnimationFrame 里抛的错,能在十秒内给你刷出上百万条记录。
1. 全局错误
window.addEventListener('error', (event) => {
const err = event.error;
// 资源加载失败走的是同一事件,但没有 error 对象
if (!err) {
push({
type: 'resource',
message: `资源加载失败: ${(event.target as HTMLElement)?.tagName}`,
url: (event.target as HTMLScriptElement)?.src || location.href,
ua: navigator.userAgent,
time: Date.now(),
});
return;
}
push({
type: 'error',
message: err.message || String(err),
stack: err.stack,
url: location.href,
ua: navigator.userAgent,
time: Date.now(),
});
}, true); // 必须开 capture,否则捕获不到资源错误
最后一个参数 capture: true 是最容易漏的地方。不开就是捕获阶段的监听,<img>、<script> 的加载失败根本进不来。
2. 未处理的 Promise
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason;
push({
type: 'unhandledrejection',
message: reason?.message || String(reason),
stack: reason?.stack,
url: location.href,
ua: navigator.userAgent,
time: Date.now(),
});
});
现在的代码几乎全是 async/await,漏掉 catch 的地方比 try/catch 多得多。这个监听器的收益往往比上面那个还大。
3. 白屏检测
白屏检测没有银弹,实用做法是抽点采样:在首帧之后检查页面几个关键位置的元素是否存在。
function checkBlank() {
const points: Array<[number, number]> = [
[0.5, 0.3], [0.5, 0.5], [0.2, 0.5], [0.8, 0.5], [0.5, 0.8],
];
const blank = points.every(([x, y]) => {
const el = document.elementFromPoint(
window.innerWidth * x,
window.innerHeight * y
);
return !el || el === document.documentElement || el === document.body;
});
if (blank) {
push({
type: 'blank',
message: '疑似白屏:首屏关键区域无有效元素',
url: location.href,
ua: navigator.userAgent,
time: Date.now(),
extra: { viewport: `${innerWidth}x${innerHeight}` },
});
}
}
window.addEventListener('load', () => {
setTimeout(checkBlank, 2000); // 给骨架屏和首屏数据留出时间
});
选五个点、取五个采样点,误报率已经能压到可以接受的水平。关键是 load 之后还要再等一会儿,不然 SPA 还在挂载阶段,必然误判。
上报:用 sendBeacon
普通 fetch 有个致命问题:用户关页面时请求会被直接掐断,最后一批错误永远丢。
function flush(useBeacon = false) {
if (!queue.length) return;
const payload = JSON.stringify({ events: queue.splice(0) });
// 页面正在卸载:交给浏览器,保证发出去
if (useBeacon && navigator.sendBeacon) {
navigator.sendBeacon('/api/monitor', new Blob([payload], { type: 'application/json' }));
return;
}
fetch('/api/monitor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
credentials: 'omit', // 监控上报不需要带 Cookie
}).catch(() => { /* 上报失败不重试,避免雪崩 */ });
}
// 定时批量发,减少请求量
setInterval(() => flush(), 5000);
// 离开页面时兜底
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush(true);
});
sendBeacon 是浏览器接管发送的,不受页面卸载影响,而且天然不阻塞。用 Blob 加类型声明可以避免部分服务端把请求当成表单解析。
另外注意 credentials: 'omit'。监控接口不应该带用户 Cookie,否则一旦有人伪造上报就是一条 CSRF 通道。
服务端:聚合与限流
上报接口必须限流,这是对外的开放端点:
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 30, // 单 IP 每分钟 30 次
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/monitor', limiter, express.json({ limit: '64kb' }), async (req, res) => {
const events = Array.isArray(req.body?.events) ? req.body.events.slice(0, 50) : [];
if (!events.length) return res.status(204).end();
await db.bulkInsertErrors(
events.map((e) => ({
type: String(e.type || 'unknown').slice(0, 32),
message: String(e.message || '').slice(0, 1000),
stack: String(e.stack || '').slice(0, 8000),
url: String(e.url || '').slice(0, 500),
user_agent: String(e.ua || '').slice(0, 300),
occurred_at: new Date(Number(e.time) || Date.now()),
}))
);
res.status(204).end();
});
对每个字段都做长度截断。否则一条超长 message 就能把库写爆,这类事故在真实项目里并不罕见。
还原:source map 必须存下来
生产代码压缩后,堆栈长这样:
at s.a (https://example.com/assets/index-9f3c.js:1:48211)
完全没法看。解决方式是在构建时生成 source map,但不发布到 CDN,只上传到监控服务端:
# 构建时生成,随后上传到内网,再删除本地文件
npx vite build --sourcemap
npx source-map-uploader dist --release $GIT_SHA
rm -f dist/**/*.map
服务端拿到堆栈后,用 source-map 包还原:
import { SourceMapConsumer } from 'source-map';
async function resolveStack(stack, release, assetsPath) {
const mapFile = findMapFile(assetsPath, release);
if (!mapFile) return stack;
const consumer = await new SourceMapConsumer(JSON.parse(mapFile));
const out = stack.replace(/\(?(https?:\/\/[^:]+):(\d+):(\d+)\)?/g, (whole, file, line, col) => {
const pos = consumer.originalPositionFor({
line: Number(line),
column: Number(col),
bias: SourceMapConsumer.GREATEST_LOWER_BOUND,
});
if (!pos.source) return whole;
return `${pos.source}:${pos.line}:${pos.column}`;
});
consumer.destroy(); // 别忘,否则 WASM 内存会一直涨
return out;
}
consumer.destroy() 这行必须写。我一开始漏了,服务跑了三天内存从 200MB 涨到 2.4GB。
还原后堆栈变成:
at handleSubmit (src/views/order/Form.vue:148:11)
定位到具体文件和行号,从"有报错但不知道在哪"变成"直接点开看"。
最后一点工程习惯
监控搭完,真正的收益来自看。我的做法是给告警加两条规则:
- 同一错误 10 分钟内出现超过 50 次 → 通知
- 白屏上报出现任意一次 → 立即通知
第二条之所以用零容忍,是因为白屏意味着用户彻底用不了,哪怕只有一例也值得看一眼。
整套监控加起来不到三百行代码,一个下午能跑通。比起装上 SDK 然后常年不看报表,自己搭的更可能被真正用起来。