4. 性能指标采集:页面加载耗时、API响应时间、JS错误堆栈捕获
一个可观测的 Web 应用必须采集三类核心性能数据:页面加载耗时(用户体验基础)、API 响应时间(后端瓶颈定位)、JS 错误堆栈(异常根因追溯)。本章从浏览器原生 API 和拦截机制出发,实现一套轻量级采集方案,并输出标准化的指标结构。
原理概述
| 指标 | 数据来源 | 核心采集时机 | 关键字段 |
|---|---|---|---|
| 页面加载耗时 | Navigation Timing API / PerformanceObserver | window.onload / LCP / FCP | dnsLookup, tcpConnect, ttfb, domInteractive, loadEventEnd |
| API 响应时间 | XMLHttpRequest / fetch 拦截 + PerformanceObserver resource | 请求完成瞬间 | url, method, status, duration, startTime |
| JS 错误堆栈 | window.onerror / window.addEventListener('unhandledrejection') | 异常抛出时 | message, stack, lineno, colno, filename |
设计原则:采集代码应当在应用初始化阶段尽早注入,且不得阻塞主流程;所有指标通过统一的消息队列上报,避免逐个请求造成网络拥塞。
实现流程
- 在页面
<head>中部署采集 SDK(同步脚本或 defer 属性)。 - 注册
PerformanceObserver监听navigation、resource和largest-contentful-paint事件。 - 拦截全局
fetch和XMLHttpRequest,记录请求开始/结束时间戳。 - 绑定
window.onerror和unhandledrejection事件,捕获未处理异常。 - 将采集到的数据序列化为 JSON,推送到内部收集队列并定时上报。
代码实现
4.1 页面加载耗时采集
使用 PerformanceObserver 监听 navigation 类型,从中提取关键阶段时间戳。
function collectPageLoadTimings() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const navEntry = entries[0];
if (!navEntry) return;
const metrics = {
type: 'page_load',
dnsLookup: navEntry.domainLookupEnd - navEntry.domainLookupStart,
tcpConnect: navEntry.connectEnd - navEntry.connectStart,
ttfb: navEntry.responseStart - navEntry.requestStart,
domInteractive: navEntry.domInteractive - navEntry.startTime,
loadEventEnd: navEntry.loadEventEnd - navEntry.startTime,
domContentLoaded: navEntry.domContentLoadedEventEnd - navEntry.startTime,
};
collect(metrics);
});
observer.observe({ type: 'navigation', buffered: true });
}
collectPageLoadTimings();
4.2 API 响应时间采集
拦截原生 fetch,记录请求开始和结束时间,并注入请求 ID 以关联响应。
const originalFetch = window.fetch;
window.fetch = async function(...args) {
const startTime = performance.now();
const requestId = crypto.randomUUID();
// 标记请求
const response = await originalFetch.apply(this, args);
const duration = performance.now() - startTime;
const metric = {
type: 'api',
url: args[0]?.url || args[0],
method: args[1]?.method || 'GET',
status: response.status,
duration,
requestId,
};
collect(metric);
return response;
};
注意:如需拦截
XMLHttpRequest,可重写 open() 和 send() 方法,利用 readystatechange 事件获取耗时,逻辑类似。
4.3 JS 错误堆栈捕获
window.onerror = function(message, source, lineno, colno, error) {
const stack = error?.stack || '';
const metric = {
type: 'js_error',
message,
source,
lineno,
colno,
stack: stack.slice(0, 2000), // 限制长度防止过大
};
collect(metric);
};
window.addEventListener('unhandledrejection', function(event) {
const reason = event.reason;
const metric = {
type: 'js_error',
message: reason?.message || String(reason),
stack: reason?.stack || '',
isUnhandledRejection: true,
};
collect(metric);
});
4.4 统一收集与上报
const metricsQueue = [];
function collect(metric) {
metric.timestamp = Date.now();
metricsQueue.push(metric);
// 队列达到 10 条或距离上次上报超过 5 秒则触发上报
if (metricsQueue.length >= 10) {
flush();
}
}
let flushTimer = null;
function scheduleFlush() {
if (flushTimer) return;
flushTimer = setTimeout(() => {
if (metricsQueue.length > 0) flush();
flushTimer = null;
}, 5000);
}
async function flush() {
if (metricsQueue.length === 0) return;
const batch = metricsQueue.splice(0, metricsQueue.length);
try {
await navigator.sendBeacon('/v1/metrics', JSON.stringify(batch));
} catch (e) {
// fallback: 使用 fetch 上报
fetch('/v1/metrics', { method: 'POST', body: JSON.stringify(batch), keepalive: true });
}
}
// 页面卸载前尝试上报剩余数据
window.addEventListener('beforeunload', flush);
调试与验证
- 在浏览器开发者工具的 Network 面板观察是否存在
/v1/metrics请求,检查 payload 是否包含预期字段。 - 在 Console 中输入
window.__METRICS_QUEUE__(若暴露)查看队列状态。 - 手动触发错误:
throw new Error('test error')并确认错误堆栈被捕获。
常见陷阱:部分浏览器(如 Safari)不支持
PerformanceObserver 的 navigation 类型;降级方案是监听 load 事件后从 performance.timing 读取数据。
部署注意事项
- 采集代码应压缩后内联或作为首屏异步脚本加载,避免影响 LCP 和 FCP。
navigator.sendBeacon在页面卸载时仍能发送请求,优先使用;若不可用则回退到fetch并设置keepalive: true。- 错误堆栈可能包含用户敏感信息(如文件路径),上报前应截断或脱敏。
常见错误与优化
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 重复采集相同指标 | 多个 PerformanceObserver 实例注册了同一类型 | 使用单例模式管理 observer |
| 上报请求阻塞页面主线程 | 同步 XMLHttpRequest 上报 | 改用 sendBeacon 或异步 fetch |
| 内存泄漏 | 队列未及时清空,累积大量记录 | 设置队列上限(如 500 条),超出强制丢弃 |
| 时间戳精度丢失 | 使用 Date.now() 而非 performance.now() | 统一使用 performance.now() 记录相对时间,结合页面加载时间戳换算时间 |
性能与稳定性优化
- 节流上报:使用 requestAnimationFrame 或定时器控制上报频率,避免高频请求。
- 采样率:对高流量页面可按 1% 比例采样,降低对服务端压力。
- 错误边界:采集代码本身用 try-catch 包裹,不得因为采集失败影响业务逻辑。
- 跨域问题:捕获跨域 JS 错误时 stack 为空,可通过设置
crossorigin="anonymous"属性获取完整堆栈。
生产级扩展:在上述采集基础上,建议增加 资源加载耗时(图片、CSS、JS)、用户行为轨迹(点击、路由变化)等指标,构建完整的用户体验图谱。