前端性能监控:Web Vitals与Sentry实战

前端性能监控是很多团队上线后才补的课。开发本地跑得飞快、Lighthouse 90 分,用户却反馈”白屏几秒””点了没反应”。根因往往是:你测的是实验室环境,用户活在真实网络、低端机和复杂交互里。本文把性能监控与错误上报串成一条可落地的链路,从 Web Vitals 指标定义,到代码采集、Sentry 接入、上报与告警,帮你把”靠猜”变成”靠数据”。它和此前那篇 前端性能优化:Lighthouse 90+ 到首屏 1s 是互补的两半——一篇讲”怎么优化”,本篇讲”怎么知道要不要优化”。

一、为什么”本地流畅”不等于”线上体验好”

本地开发有三个天然优势:资源在本地、网络是回环、设备是顶配。而真实用户分布在全国各地的 4G/弱网、三年前的安卓机、后台挂着十几个标签页的浏览器里。所以单纯在 CI 里跑一次 Lighthouse,只能证明”理论上能快”,证明不了”实际够快”。

要回答真实体验,必须用真实用户监控(RUM,Real User Monitoring):在用户浏览器里埋点,把每次访问的耗时、错误、交互延迟回传。它和长期性能优化(编译提速、包体瘦身)不冲突,而是互相印证的两套数据。

二、Web Vitals:和用户体感对齐的三件套

Google 的 Core Web Vitals 把”体验”收敛成三个可量化指标。它们不是工程师拍脑袋定的,而是分别对应”加载慢不慢、交互卡不卡、视觉稳不稳”。

指标衡量什么优秀线差的典型原因
LCP(最大内容绘制)首屏主要内容加载完成的时间≤ 2.5s大图未压缩、关键请求阻塞、未用 CDN
INP(交互到下次绘制)用户点击/输入到画面响应的延迟≤ 200ms主线程被长任务占满、同步重排
CLS(累积布局偏移)页面元素意外跳动的程度≤ 0.1图片无宽高、异步插入广告/弹窗

注意 INP 在 2024 年已取代旧版 FID 成为核心指标。FID 只测”第一次交互”,而 INP 看”整个会话里最差的那次响应”,更贴近用户真实体感。如果你的技术栈还在用老教程里的 FID,是时候更新概念了。

三、用官方 web-vitals 库采集核心指标

别自己造轮子计算这些指标,浏览器底层的 PerformanceObserver 取值规则很绕。直接用 Google 官方的 web-vitals 库,它已经处理了各类边界情况。

// 安装:npm i web-vitals
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

function report(metric) {
  // metric 含 name / value / rating('good'|'needs-improvement'|'poor') / id
  navigator.sendBeacon('/api/perf', JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    path: location.pathname,
    ua: navigator.userAgent,
  }));
}

onLCP(report);
onINP(report);
onCLS(report);
onFCP(report);
onTTFB(report);

上报用 navigator.sendBeacon 而非 fetch,是因为卸载阶段(页面关闭/跳转)普通请求常被浏览器直接丢弃,而 sendBeacon 专为”可靠地把少量数据发给服务器”设计。这块和 Vue3 + TypeScript 工程化 这类工程里的埋点封装是一脉相承的。

四、错误捕获:把线上异常抓回来

性能慢能忍,报错直接白屏。前端错误捕获有三道网,缺一不可。

// 1. 全局 JS 运行时错误(资源加载错误需 capture 阶段监听)
window.addEventListener('error', (e) => {
  // e.error 是 Error 对象,含 stack
  reportError({ msg: e.message, stack: e.error?.stack, line: e.lineno, col: e.colno });
}, true);

// 2. 未被 catch 的 Promise 拒绝(最常见于接口失败未兜底)
window.addEventListener('unhandledrejection', (e) => {
  const reason = e.reason?.stack || e.reason?.message || String(e.reason);
  reportError({ type: 'unhandledrejection', msg: reason });
});

// 3. 关键业务用 try/catch + 自定义上下文
async function loadOrder(id) {
  try {
    return await api.getOrder(id);
  } catch (err) {
    reportError({ msg: err.message, context: { orderId: id } });
    throw err; // 继续向上抛,保住调用方逻辑
  }
}

一个常见坑:window.onerror 对跨域脚本(CDN 上的第三方 JS)默认拿不到详细堆栈,只能看到 “Script error.”。解决方法是给外部脚本加 crossorigin="anonymous" 并且 CDN 返回 Access-Control-Allow-Origin 响应头,否则你永远看不到第三方脚本到底崩在哪。

五、接入 Sentry:从堆栈到影响面一步到位

自己搭错误收集服务当然可以,但绝大多数团队用 Sentry 性价比最高:它自动聚合相似错误、保留源码映射(source map)、能区分”影响了 1 个用户还是 1 万个用户”,还支持按版本、浏览器、路由下钻。

// 安装:npm i @sentry/browser @sentry/tracing
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: 'https://<key>@o<org>.ingest.sentry.io/<project>',
  release: 'web-app@1.4.2',        // 与发版号对齐,才能定位到引入 bug 的版本
  environment: 'production',
  tracesSampleRate: 0.2,           // 性能采样率,别开 1.0 否则费用爆炸
  sampleRate: 1.0,                 // 错误全量采集(错误比性能便宜且更重要)
  beforeSend(event) {
    // 过滤掉可忽略的噪音,或脱敏敏感字段
    if (event.request?.url?.includes('/health')) return null;
    return event;
  },
});

// 给每次会话附加上下文,出问题时知道是谁、在哪个页面
Sentry.setTag('app_version', '1.4.2');
Sentry.setUser({ id: userId });   // 注意脱敏,别传手机号等 PII

部署时务必上传 source map,否则 Sentry 里看到的堆栈是压缩后的 a.dX(...) ,排查毫无意义。Vite/Webpack 构建产物都支持在发版后调用 sentry-cli@sentry/webpack-plugin 自动上传。这一步可以无缝接进 GitHub Actions 实战:从零搭建 CI/CD 流水线 的发版步骤里,做到”每次构建自动回传 map”。

六、上报到自有平台:sendBeacon 与批量缓冲

如果数据要进自己的分析库或大模型告警体系,需要自己接收端点。直接用裸 sendBeacon 在弱网下会丢,建议加一层本地缓冲 + 定时批量发送。

const queue = [];

function push(metric) {
  queue.push({ ...metric, t: Date.now() });
  if (queue.length >= 10) flush();           // 攒够 10 条发一次
}

// 页面隐藏/卸载时强制清空队列
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

function flush() {
  if (!queue.length) return;
  const body = JSON.stringify(queue.splice(0));
  // sendBeacon 失败(如数据过大)退回 fetch keepalive
  if (!navigator.sendBeacon('/api/telemetry', body)) {
    fetch('/api/telemetry', { method: 'POST', body, keepalive: true });
  }
}

七、采样与告警:别被噪音淹没

监控上了之后最大的敌人不是没数据,而是数据太多。每个用户每次访问都上报,错误一爆发就是几千条同款堆栈。必须靠采样和聚合把信号提纯。

数据类型建议策略告警触发条件
性能指标(Web Vitals)按会话采样 10%~30%某路由 P75 LCP 连续 5 分钟超 4s
JS 运行时错误全量采集,按指纹聚合同一错误 1 分钟内影响 > 50 用户
接口失败率全量,按接口维度统计某接口错误率 > 5% 持续 3 分钟
静态资源加载失败按资源 URL 聚合某 CDN 资源失败率突增

告警阈值切忌拍脑袋。先观察两周的基线,再用”基线 + 三倍标准差”设定上界,否则告警要么永不来(阈值太高),要么半夜狂响(阈值太低),两种结局都会让人最终把告警静音。

八、把监控接进整体观测体系

前端监控不是孤岛。一次”页面打不开”,可能是前端 bug,也可能是网关超时、后端 5xx、CDN 回源失败。把前端上报、Nginx 日志、服务端指标拉到同一张 Grafana 看板上,才能快速定位责任边界。链路建设思路可参考 Prometheus + Grafana 监控面板实战:前端把关键错误也打一条指标,和后端 RED(请求数/错误数/时延)指标放在一屏对照。

// 把前端错误也曝成 Prometheus 可抓的计数器思路(经自建网关中转)
// 前端 -> /api/telemetry -> 网关累加 metrics_frontend_errors_total{route,type}
// prometheus.yml 中 scrape 该网关,Grafana 面板与 backend RED 同屏

九、落地清单

按下面这份顺序推进,半天就能跑通第一版,再逐步加厚:

  • 先接 web-vitals,把 LCP/INP/CLS 上报到自己的端点,建立基线;
  • 补全局 error + unhandledrejection 捕获,错误先全量落库;
  • 接 Sentry,上传 source map,release 与发版号对齐;
  • 外部脚本加 crossorigin,避免 “Script error.” 黑盒;
  • 设采样率与聚合告警,用两周基线定阈值;
  • 把前端指标并入统一观测看板,与后端指标对照。

最后一句:性能优化是”让快的更快”,监控是”让慢的无处可藏”。没有监控的优化像闭眼开车,而只有监控不优化则永远在救火。先把 RUM 跑起来,你才会发现那些藏在低端机和弱网里的真实卡顿。

上一篇 vLLM 部署实战:高吞吐 LLM 推理服务调优
下一篇 Kafka 入门实战:消息队列选型与可靠性保障