前端监控与错误追踪
前端监控与错误追踪
前端监控是保障 Web 应用质量的核心手段。它通过在真实用户的浏览器中采集性能指标、错误堆栈和行为数据,让团队在用户投诉之前就发现问题、定位问题并量化优化效果。可以把前端监控理解为给你的线上应用装上一套"仪表盘 + 黑匣子":仪表盘实时告诉你应用跑得快不快、稳不稳;黑匣子则在事故发生时完整记录现场,帮你还原案发经过。
在没有监控的时代,前端团队常常处于"盲飞"状态:本地一切正常、测试环境一切正常,但线上用户却在某个特定机型、特定浏览器、特定网络下遭遇白屏或卡顿。这类问题往往难以复现,靠人工排查如同大海捞针。前端监控的价值,正是把"用户遇到的真实问题"变成"团队看板上可量化、可追踪、可告警的数据"。
为什么前端监控如此重要
概念:前端监控(Frontend Monitoring / RUM,Real User Monitoring)指在真实用户环境中,持续采集页面性能、运行时错误、资源加载、接口调用与用户行为等数据,并上报到监控平台进行聚合、分析与告警的一整套体系。
为什么重要:
类比:如果把线上应用比作一家 24 小时营业的餐厅,前端监控就是同时安装了三样东西——后厨的测温计(性能)、门口的监控摄像头(错误现场)、以及收银台的流水账(用户行为与转化)。缺了任何一样,出了问题都只能靠事后猜测。
监控体系全景:四大监控类型
一套完整的前端监控体系通常覆盖四个维度,它们回答四个不同层次的问题。
性能监控:应用跑得快不快?
错误监控:应用稳不稳?
用户行为监控:用户怎么用?
业务监控:产品好不好?
| 监控类型 | 回答的问题 | 典型指标 | 主要工具 |
| --- | --- | --- | --- |
| 性能监控 | 跑得快不快 | LCP、INP、CLS、TTFB | web-vitals、Sentry、Datadog |
| 错误监控 | 稳不稳 | JS 错误率、崩溃率、MTTR | Sentry、Bugsnag |
| 行为监控 | 怎么用 | PV、UV、点击热图、会话录制 | GA4、Clarity、Amplitude |
| 业务监控 | 好不好 | 转化率、漏斗、留存 | GA4、自建数仓 |
小结:四类监控层层递进,从"技术健康度"走向"业务价值"。真正成熟的团队会把它们打通——例如把某次错误率飙升与当天转化率下跌关联起来,形成完整的因果链路。
Core Web Vitals:以用户为中心的性能度量
概念与背景
Core Web Vitals(核心网页指标,简称 CWV)是 Google 提出的一组以用户真实体验为中心的性能指标,用于量化"加载体验""交互体验"和"视觉稳定性"三个维度。它不仅影响用户感知,还直接参与 Google 搜索排名。
为什么重要:传统的性能指标(如 `load` 事件、`DOMContentLoaded`)衡量的是"浏览器完成了什么",而不是"用户感受到了什么"。一个页面可能 `load` 事件很早触发,但主内容图片迟迟不显示,用户依旧觉得慢。Core Web Vitals 的设计哲学正是"从用户视角出发"。
三大核心指标与两个辅助指标
LCP(Largest Contentful Paint,最大内容绘制):衡量加载性能,即视口内最大的内容元素(通常是主图或大段文字)完成渲染的时间点。
INP(Interaction to Next Paint,交互到下一次绘制):衡量交互响应性,统计整个页面生命周期中所有交互(点击、按键)的延迟,取接近最差的一次。特别注意:INP 已于 2024 年 3 月正式取代 FID(First Input Delay,首次输入延迟)成为 Core Web Vitals 之一。 FID 只测量"首次交互的输入延迟",且只算延迟不算处理与渲染时间,容易给出过于乐观的分数;INP 则覆盖全生命周期的所有交互并包含处理与绘制耗时,是更严格、更贴近真实体验的指标。做新项目应直接采集 INP,老项目也应尽快迁移。
CLS(Cumulative Layout Shift,累积布局偏移):衡量视觉稳定性,即页面元素在加载过程中意外移动的程度。典型场景是"你正要点按钮,一张广告图突然加载把按钮挤走,你点到了别处"。
FCP(First Contentful Paint,首次内容绘制):辅助指标,标记浏览器首次渲染出任意内容(文本、图片)的时间,反映"白屏"结束的时刻。
TTFB(Time to First Byte,首字节时间):辅助指标,从发起请求到收到响应第一个字节的时间,主要反映网络与服务端处理速度,是 LCP 的上游因素。
阈值对照表
Google 对每个指标定义了 good / needs-improvement / poor 三档,评判以"字段数据的 75 分位值"为准(即 75% 的用户访问达到该标准)。
| 指标 | Good(良好) | Needs Improvement(待改进) | Poor(差) | 衡量维度 |
| --- | --- | --- | --- | --- |
| LCP | 小于等于 2.5 秒 | 2.5 至 4.0 秒 | 大于 4.0 秒 | 加载性能 |
| INP | 小于等于 200 毫秒 | 200 至 500 毫秒 | 大于 500 毫秒 | 交互响应 |
| CLS | 小于等于 0.1 | 0.1 至 0.25 | 大于 0.25 | 视觉稳定 |
| FCP | 小于等于 1.8 秒 | 1.8 至 3.0 秒 | 大于 3.0 秒 | 首次绘制 |
| TTFB | 小于等于 800 毫秒 | 800 至 1800 毫秒 | 大于 1800 毫秒 | 网络与服务端 |
原理简述:这些指标既可以在"实验室环境"(Lab,如 Lighthouse 模拟)采集,也可以在"真实用户环境"(Field / RUM)采集。搜索排名与真实体验以 Field 数据为准,因此线上一定要做 RUM 采集,而不能只看本地 Lighthouse 跑分。
用 web-vitals 库采集并上报
Google 官方维护的 `web-vitals` 库是采集 Core Web Vitals 最省心的方式,它屏蔽了各指标复杂的计算细节,并处理了 bfcache、tab 切换等边界情况。
// npm i web-vitals
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
// 统一的上报函数:优先用 sendBeacon,保证页面卸载时也能送达
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name, // LCP / INP / CLS / FCP / TTFB
value: metric.value, // 指标数值
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta, // 相比上次上报的增量
id: metric.id, // 本次页面加载的唯一 id
navigationType: metric.navigationType,
page: location.pathname,
ts: Date.now(),
});
const url = '/api/rum';
// sendBeacon 在页面卸载时依然可靠,是上报性能数据的首选
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
fetch(url, { body, method: 'POST', keepalive: true });
}
}
// 注册各指标的回调,指标"确定"后会触发
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);常见坑:CLS 与 INP 都是"页面生命周期内持续累积/更新"的指标,只有在页面进入后台或卸载时才有最终值。如果你在 `load` 事件里就读取它们,拿到的往往是不完整数据。`web-vitals` 库已经帮你处理了这些时机,不要自己在 `load` 里草率取值。
最佳实践:上报时带上页面路由、设备类型、网络类型(`navigator.connection.effectiveType`)、是否命中缓存等维度,这样才能在看板上按维度下钻,例如发现"某慢在 4G 移动端"这种精细结论。
Performance API 与 PerformanceObserver
概念
浏览器原生提供了 Performance API,它把加载、渲染、资源、交互等各类"性能条目"(PerformanceEntry)存放在一条时间线上。`PerformanceObserver` 则是订阅这些条目的推荐方式——相比轮询 `performance.getEntries()`,它是事件驱动的,不会漏掉早期条目,也不会造成无谓的轮询开销。
为什么用 PerformanceObserver 而不是直接读取:直接读 `performance.getEntriesByType()` 有两个问题——一是可能错过在你代码执行前就已产生的条目,二是需要自己轮询。`PerformanceObserver` 支持 `buffered: true` 选项,能把订阅之前缓冲的历史条目一并回放,彻底解决"错过早期条目"的问题。
采集 LCP
// 采集 LCP:观察 'largest-contentful-paint' 条目,取最后一个
function observeLCP(callback) {
let lcpValue = 0;
const po = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
// LCP 会随渲染不断更新,最后一个才是最终值
const lastEntry = entries[entries.length - 1];
lcpValue = lastEntry.renderTime || lastEntry.loadTime;
});
po.observe({ type: 'largest-contentful-paint', buffered: true });
// 用户首次交互或页面隐藏后,LCP 不再更新,此时上报
const report = () => {
po.takeRecords();
po.disconnect();
callback(lcpValue);
};
['keydown', 'click'].forEach((t) =>
addEventListener(t, report, { once: true, capture: true })
);
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') report();
});
}
observeLCP((value) => console.log('LCP:', value, 'ms'));采集 CLS
// 采集 CLS:累加非用户输入引起的布局偏移,采用"会话窗口"最大值算法
function observeCLS(callback) {
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];
const po = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 只统计非用户主动交互引起的偏移
if (entry.hadRecentInput) continue;
const first = sessionEntries[0];
const last = sessionEntries[sessionEntries.length - 1];
// 同一会话窗口:间隔小于 1s 且总时长小于 5s
if (
sessionValue &&
entry.startTime - last.startTime < 1000 &&
entry.startTime - first.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
// 取所有会话窗口中的最大值作为 CLS
if (sessionValue > clsValue) {
clsValue = sessionValue;
callback(clsValue);
}
}
});
po.observe({ type: 'layout-shift', buffered: true });
}
observeCLS((value) => console.log('CLS:', value.toFixed(3)));采集 INP(交互延迟)
// 采集 INP:观察 'event' 条目,统计交互从输入到下一帧绘制的延迟
function observeINP(callback) {
let worstINP = 0;
const po = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// duration 覆盖了输入延迟 + 事件处理 + 渲染延迟
if (entry.interactionId && entry.duration > worstINP) {
worstINP = entry.duration;
callback(worstINP, entry.name);
}
}
});
// durationThreshold 过滤掉过短、无意义的交互
po.observe({ type: 'event', buffered: true, durationThreshold: 40 });
}
observeINP((value, type) =>
console.log(`INP: ${value}ms (由 ${type} 交互产生)`)
);采集资源加载时间
// 采集资源加载耗时:定位慢资源(大图、第三方脚本)
const resourceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 只关注加载超过 500ms 的慢资源
if (entry.duration > 500) {
console.warn('慢资源:', {
name: entry.name,
type: entry.initiatorType, // script / img / css / fetch
duration: Math.round(entry.duration),
transferSize: entry.transferSize, // 实际传输字节数
cached: entry.transferSize === 0, // 命中缓存则为 0
});
}
}
});
resourceObserver.observe({ type: 'resource', buffered: true });常见坑:跨域资源默认只暴露有限的 timing 信息,`transferSize`、`domainLookupStart` 等字段会是 0。若要拿到完整数据,需要资源服务端返回 `Timing-Allow-Origin` 响应头。
小结:`web-vitals` 库适合"开箱即用地拿标准指标",而 `PerformanceObserver` 适合"需要自定义采集逻辑、下钻到资源粒度"的场景。两者常常配合使用——用 web-vitals 拿核心分数,用 PerformanceObserver 补充资源级细节。
错误捕获全攻略
前端错误的来源五花八门:同步代码抛错、异步 Promise 未捕获、资源加载失败、框架渲染崩溃……单一手段无法覆盖所有情况,必须组合多种捕获机制,织成一张"错误捕获网"。
window.onerror:捕获同步运行时错误
原理:`window.onerror` 是最古老的全局错误钩子,能捕获未被 try-catch 拦截的同步 JS 运行时错误。它的回调能拿到消息、文件、行列号和 Error 对象。
// 全局同步错误捕获
window.onerror = function (message, source, lineno, colno, error) {
reportError({
kind: 'js_error',
message: String(message),
source, // 出错脚本 URL
lineno, // 行号
colno, // 列号
stack: error && error.stack, // 完整堆栈(用于 Source Map 还原)
ua: navigator.userAgent,
page: location.href,
ts: Date.now(),
});
// 返回 true 可阻止错误继续冒泡到控制台,通常返回 false 保留默认行为
return false;
};常见坑:跨域脚本抛出的错误,`window.onerror` 只会收到一个信息量为零的 `"Script error."`,行列号和堆栈全部丢失。解决办法是给 `