4. 性能指标采集:页面加载耗时、API响应时间、JS错误堆栈捕获

一个可观测的 Web 应用必须采集三类核心性能数据:页面加载耗时(用户体验基础)、API 响应时间(后端瓶颈定位)、JS 错误堆栈(异常根因追溯)。本章从浏览器原生 API 和拦截机制出发,实现一套轻量级采集方案,并输出标准化的指标结构。

原理概述

指标数据来源核心采集时机关键字段
页面加载耗时Navigation Timing API / PerformanceObserverwindow.onload / LCP / FCPdnsLookup, 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
设计原则:采集代码应当在应用初始化阶段尽早注入,且不得阻塞主流程;所有指标通过统一的消息队列上报,避免逐个请求造成网络拥塞。

实现流程

  1. 在页面 <head> 中部署采集 SDK(同步脚本或 defer 属性)。
  2. 注册 PerformanceObserver 监听 navigation、resource 和 largest-contentful-paint 事件。
  3. 拦截全局 fetch 和 XMLHttpRequest,记录请求开始/结束时间戳。
  4. 绑定 window.onerror 和 unhandledrejection 事件,捕获未处理异常。
  5. 将采集到的数据序列化为 JSON,推送到内部收集队列并定时上报。
性能指标采集流程图 浏览器页面加载 页面加载耗时 API 响应时间 JS 错误堆栈 采集队列 & 上报 APM 后端存储 每个采集器独立执行,通过 timeOrigin、performance.now() 统一时间基准,避免时钟漂移

代码实现

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)、用户行为轨迹(点击、路由变化)等指标,构建完整的用户体验图谱。