7AA7 博客

前端性能监控:指标采集与异常上报

Maren UID:86
2026-09-06 21:00
94 3 3

自己搭一套监控,难点不在”怎么发请求”,而在采集口径是否稳定、数据能否还原现场、上报是否影响性能本身。这三件事决定了数据是资产还是噪音。

PerformanceObserver 采集要点

核心指标都通过 PerformanceObserver 拿,而不是 performance.timing(后者已被标记为废弃,且语义是导航时间点不是用户体验指标)。

function observe(type, handler, opts = {}) {
  try {
    const po = new PerformanceObserver(list => handler(list.getEntries()));
    po.observe({ type, buffered: true, ...opts });
    return po;
  } catch (e) { /* 旧浏览器不支持该 type,静默降级 */ }
}

buffered: true 是关键。监听器注册通常晚于指标产生时刻,不加这个参数就会漏掉已经进入缓冲区的条目。

LCP 会持续更新——更大的元素加载完后会推送新条目。正确做法是每次收到就覆盖,页面隐藏时上报最新一条:

let lcpValue = 0, lcpEntry = null;
observe('largest-contentful-paint', entries => {
  for (const e of entries) {
    lcpValue = e.startTime;
    lcpEntry = e;
  }
});
// 页面隐藏时再上报,此时 LCP 才稳定

LCP 的归因信息很有价值:entry.element、entry.url、entry.size、entry.renderTime。知道 LCP 元素是那张 800KB 的 banner 图,比只知道 3.2 秒有用得多。

CLS 用 layout-shift 条目,必须过滤掉用户交互引发的合理偏移:

let cls = 0;
observe('layout-shift', entries => {
  for (const e of entries) {
    if (!e.hadRecentInput) cls += e.value;
  }
});

e.sources 里能拿到偏移的节点,做归因时抓 sources[0].node 的选择器路径。

INP 需要监听 event 类型并取 duration:

let inp = 0, worstTarget = '';
observe('event', entries => {
  for (const e of entries) {
    if (e.duration > inp) { inp = e.duration; worstTarget = e.name; }
  }
}, { durationThreshold: 16 }); // 只观察超过阈值的

durationThreshold 默认是 104ms,想看完整的交互分布要调低,代价是条目变多、处理开销上升。生产上通常设 40 或 50ms 之间做权衡。

三个指标都要在页面隐藏(visibilitychange → hidden)或 pagehide 时统一上报,避免中途取值不准。

SPA 的指标口径

SPA 里”首屏”和”路由切换”必须分开,否则数据会互相污染。

  • 首屏指标:只在第一次导航采集,LCP/CLS 在首次 pagehide 或切路由时封口;
  • 路由切换指标:不能简单复用 LCP(浏览器不会在软导航后再报 LCP)。可用 navigation 条目配合框架自己的计时,或在路由切换后手动打点:开始时间记在路由拦截里,结束时间取自新页面主内容渲染完成。
// 软导航近似:手动埋点 + 渲染完成时机
router.beforeEach(() => { mark('route-start'); });
router.afterEach(() => {
  requestAnimationFrame(() => requestAnimationFrame(() => {
    const t = performance.now() - mark('route-start').startTime;
    report({ type: 'route-render', value: t, path: location.pathname });
  }));
});

双 requestAnimationFrame 是为了跳过当前帧的提交,等下一帧真正绘制后再计时。

上报时一定要带上 path 或路由名。全站聚合的 LCP 意义有限,按页面分组才看得出问题。

错误捕获的三个来源与盲区

1. window.onerror / error 事件——捕获同步运行时错误:

window.addEventListener('error', e => {
  // e.error 是 Error 对象,e.message / filename / lineno / colno
  report({ type: 'js', message: e.message, stack: e.error?.stack });
}, true); // 捕获阶段,资源错误也能收到

盲区:Promise 内的错误走另一条路;setTimeout 回调里的错误能捕获但丢失部分上下文;跨域脚本的错误只有 Script error.,拿不到堆栈。

跨域脚本的解决办法:给 <script> 加 crossorigin="anonymous",同时服务端返回 Access-Control-Allow-Origin。两边缺一不可。

2. unhandledrejection——未处理的 Promise 拒绝:

window.addEventListener('unhandledrejection', e => {
  e.preventDefault(); // 阻止默认控制台输出,按需决定
  const r = e.reason;
  report({ type: 'promise', message: r?.message ?? String(r), stack: r?.stack });
});

盲区:只有”没人 catch”的 rejection 才触发。写了 .catch(() => {}) 但吞掉了错误,监控看不到——这是最隐蔽的一类问题,需要在代码规范层面约束(catch 里必须上报或显式忽略注释)。

3. 资源加载错误——img / script / link 加载失败:

window.addEventListener('error', e => {
  const t = e.target;
  if (t instanceof HTMLElement && t !== window) {
    report({ type: 'resource', tag: t.tagName, url: t.src || t.href });
  }
}, true); // 必须在捕获阶段

必须传第三个参数 true,因为资源错误不冒泡,只在捕获阶段能收到。

另外框架自身的错误边界(React 的 ErrorBoundary、Vue 的 app.config.errorHandler)要接上,它们捕获的是渲染期异常,冒泡到 window 时可能已经被框架处理成”组件卸载”而丢失原始信息。

SourceMap 还原堆栈

生产代码是压缩过的,堆栈长这样:

TypeError: Cannot read properties of undefined (reading 'x')
    at o.<anonymous> (https://cdn.example.com/app.8f2a.js:1:28471)

还原需要 sourcemap,但不能把 sourcemap 暴露到公网——它包含完整源码、注释,甚至可能含内部逻辑和敏感信息。

标准做法是服务端离线还原:

  1. 构建时生成 sourcemap,通过 CI 上传到内部的符号服务器(按版本号 + 文件名索引),产物里的 //# sourceMappingURL= 注释删除或不上传 map 文件到 CDN;
  2. 监控平台收到堆栈后,后端根据堆栈里的文件名 + 版本号拉取对应 map,用 source-map 库还原出原始文件名、行号、列号;
  3. 还原后的结果做聚合(同一处错误合并),原始压缩堆栈保留备查。
// 服务端还原示意
const { SourceMapConsumer } = require('source-map');
const raw = await consumer.originalPositionFor({ line, column });
// { source: 'src/views/Order.tsx', line: 42, column: 17, name: 'onSubmit' }

工程细节:
– 版本号必须与产物严格对应,构建产物和 map 要有同一个 build id;
– map 文件体积可能几十 MB,按需懒加载 + 缓存;
– 还原失败要降级展示原始堆栈,不能因为还原失败就丢错误;
– 只上报错误的 message + 堆栈前 N 帧,避免把整个调用栈都传上去。

采样与上报时机

全量上报在流量大的站点上会打爆服务,采样是必需的:

function shouldSample(rate) { return Math.random() < rate; }
// 错误全量、性能指标按比例
if (type === 'error' || shouldSample(0.1)) send(payload);

错误一般全量(低频且重要),性能指标按 5%~20% 采样。注意采样要在用户维度做(用稳定的会话 id 哈希),否则同一个用户每次访问进/不进样本会漂移,分析留存类问题时数据会错乱。

上报时机用 navigator.sendBeacon,它在页面卸载时也能可靠发出,且不阻塞导航:

const queue = [];
function report(data) {
  queue.push(data);
  if (queue.length >= 10) flush();
}
function flush() {
  if (!queue.length) return;
  const body = JSON.stringify({ events: queue.splice(0) });
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/collect', new Blob([body], { type: 'application/json' }));
  } else {
    fetch('/collect', { method: 'POST', body, keepalive: true });
  }
}
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

visibilitychange → hidden 比 beforeunload 可靠得多,移动端上 beforeunload 经常不触发。

sendBeacon 的限制要知道:单次体积有限(通常几十 KB 量级),没有响应回调(无法确认成功),且并发请求数受限。所以必须批量 + 丢弃超长内容。

会话标识与归因维度

一条上报记录如果只有指标值,几乎没法定位问题。有价值的归因维度包括:

  • 页面标识:规范化后的路由(/order/:id 而不是 /order/88213),否则高基数 URL 会让聚合失效;
  • 版本:应用版本号与构建 id,用于对比发布前后的变化;
  • 设备与网络:navigator.deviceMemory、硬件并发数、navigator.connection.effectiveType、DPR、是否省电模式;
  • 会话:一个稳定 id,用于串联同一次访问的多个指标。
const dims = {
  sid: sessionStorage.getItem('sid') ?? crypto.randomUUID(),
  path: normalizePath(location.pathname),
  build: BUILD_ID,
  dpr: devicePixelRatio,
  mem: navigator.deviceMemory ?? -1,
  conn: navigator.connection?.effectiveType ?? 'unknown',
};

URL 规范化这一步特别容易漏。直接把 location.pathname 上报,聚合结果会被每个订单 id 拆成一条,看不出任何规律。

监控自身的可靠性

采集脚本要能被验证。做法是埋一个已知的心跳事件:页面加载后固定上报一条带版本号的记录,服务端检查每个版本的心跳到达量。某个版本心跳突然消失,说明采集链路断了,而不是数据变好了。

还有三点:采集脚本加载失败要有兜底记录;服务端要有清洗规则,过滤掉负值、超大值这类明显异常的数据;原始日志保留一段时间,聚合口径改动后可以重算。

指标看板旁边要配一条”采集量”曲线。指标本身好看而采集量掉了一半,通常意味着脚本在某个版本崩了。

数据量控制

监控自己不能成为性能问题。几条硬约束:

  • 上报脚本本身要小且异步加载,不能阻塞主流程;
  • 同类型错误做客户端去重,同一个 message + 同一堆栈在会话内只报一次,附一个 count;
  • 堆栈截断到前 10 帧,message 截断到合理长度;
  • 不做同步 XHR(已废弃且严重阻塞),不用 XMLHttpRequest 同步模式;
  • 采集脚本自身包一层 try/catch,监控出错不能影响业务;
  • 设置单页面单会话的条目上限,防止死循环刷爆。
let sent = 0;
const MAX = 50;
function report(data) {
  if (sent++ > MAX) return;
  try { /* ... */ } catch { /* 静默 */ }
}

数据落地后还要设计聚合视图:按错误指纹(message 归一化 + 首帧位置)分组、按版本对比新增/回归、按页面和设备的 P75/P95 分布。没有这些视图,采集得再多也只是堆数字。

1.6k浏览量
94赞
3评论
3参与用户
16 分钟阅读时间

评论(3)

  • 顾清池 · 2026-09-06 23:28

    我们团队为这个吵了一下午,最后按你说的做了。

    回复
  • Talen · 2026-09-07 08:36

    不太理解为什么不用现成的中间件,自己写维护成本不低。

    回复
  • 许清欢 · 2026-09-07 17:41

    这段我抄了,回头请你喝奶茶(虚拟的)。

    回复