前端性能监控是很多团队上线后才补的课。开发本地跑得飞快、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 跑起来,你才会发现那些藏在低端机和弱网里的真实卡顿。




