前端监控

本文最后更新于 2025年10月5日 上午

参考:
https://juejin.cn/post/6844903953788829704
https://juejin.cn/post/7219669812158414903
https://juejin.cn/post/7223280402475089978

一个页面性能差的话会大大影响用户体验。用户打开页面等待的太久,可能会直接关掉页面甚至不再使用,这种情况在移动端更加明显,移动端用户对页面响应延迟容忍度很低。

虽然页面性能很重要,但是在实际使用中,页面性能差的情况并不少见。首先,在产品的迭代演进过程中,页面性能可能会被忽略,性能随着版本迭代而有所衰减;其次,性能优化是一项复杂而挑战的事情,需要明确的优化方向和具体的优化手段才能快速落地取效。

所以需要一个性能监控系统,持续监控和预警页面性能的状况,并且在发现瓶颈时指导优化工作。

监控方面

性能监控

用于收集页面加载和渲染的相关性能指标。

页面整体:

  • 起始点、首字节、白屏时间、首屏时间
  • DOMContentLoaded 时间、load 时间

用户体验:

  • FCP(First Contentful Paint):首次内容绘制
  • LCP(Largest Contentful Paint):最大内容绘制
  • FID(First Input Delay):首次输入延迟
  • CLS(Cumulative Layout Shift):页面抖动
  • TBT(Total Blocking Time):阻塞总时长,表示页面加载过程所有长任务阻塞主线程的总时间。它也可以通过监听页面上的长任务来计算。当一个长任务的执行时间超过 50 毫秒时,我们认为它阻塞了主线程
  • TTI(Time to Interactive):可交互时间,从用户发起请求到页面可以正常响应用户操作所经过的时间。这个指标反映了用户等待页面可交互的时间,对用户体验有很大影响

资源监控

用于监控页面中各种资源(例如图片、脚本、样式表等)的加载情况,以及它们对页面性能的影响。

  • HTML、CSS、JS、图片等资源的大小和加载耗时
    • DNS 解析时间、TCP 建连时间、SSL 握手时间
  • API 请求耗时、成功率、错误率
    • DNS 解析时间、TCP 建连时间、SSL 握手时间

运行监控

  • 长任务(Long Task > 50ms)
  • 功耗和内存使用情况(SPA 特别关注内存泄漏)
  • 帧率 FPS(滚动/动画是否卡顿/掉帧)

异常监控

  • JS 报错(window.onerrorunhandledrejection
  • 资源加载错误
  • 接口请求失败

用户行为监控

用于收集用户在网站或应用中的操作信息,例如点击、滚动、输入等。

数据采集

  • 调试工具

    • 浏览器控制台 Network(单个资源分析、DOMContentLoaded 时间、load 时间)
    • 浏览器控制台 Lighthouse(FCP、LCP、CLS)
    • 浏览器控制台 Peformance(详细分析性能)
    • 三方测试工具 webPageTest(模拟在不同地区用不同浏览器下的访问性能)
    • webpack 开启 source-map 和 webpack-bundle-analyzer(资源打包分析)
  • 监控 API

    • window.performance
    • web-vitals(FCP、LCP、CLS、FID)
    • const observer = new PerformanceObserver((list) => {})
    • window.addEventListener('DOMContentLoaded', (event) => {})
    • window.addEventListener('load', (event) => {})

自定义埋点

  • API 请求封装:统一拦截、记录请求耗时
  • 用户关键路径打点:比如首屏渲染完成、按钮点击到结果展示

可以根据实际需求定义自定义的埋点数据结构,通常包含:

  • 事件名称 (event): 区分不同类型的事件
  • 事件触发时间 (timestamp): 用来计算事件的触发时长,便于后续分析
  • 页面信息 (page): 当前页面的路径、标题等
  • 用户信息 (user): 比如用户 ID、登录状态、设备信息(这部分需要提前从用户的上下文中提取)
  • 额外数据 (data): 例如按钮文本、输入框内容等

用户行为监控

针对用户点击、滚动、输入、使用 document.addEventListener 进行监控,根据传递参数的不同来监控不同的事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 定义一个发送数据的函数
// 这里 后续会改进掉 使用img.src的方式进行数据发送 具体原因也会在后面详细说明
function sendData(data) {
// 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
// 例如:
// fetch('/api/track', {
// method: 'POST',
// body: JSON.stringify(data),
// headers: {
// 'Content-Type': 'application/json'
// }
// });
}

// 监听点击事件
document.addEventListener("click", function (event) {
// 获取点击的元素
let target = event.target;
console.log("我点击了~~", event);

// 获取元素的相关信息,例如ID、类名等
let id = target.id;
let className = target.className;

// 构造要发送的数据
let data = {
type: "click",
id: id,
className: className,
// 其它你想要收集的信息
};

// 发送数据
sendData(data);
});

// 监听滚动事件
document.addEventListener("scroll", function (event) {
// 获取滚动位置
let scrollTop = document.documentElement.scrollTop;

// 构造要发送的数据
let data = {
type: "scroll",
scrollTop: scrollTop,
// 其它你想要收集的信息
};

// 发送数据
sendData(data);
});

// 监听输入事件
document.addEventListener("input", function (event) {
// 获取输入的元素和值
let target = event.target;
let value = target.value;

// 构造要发送的数据
let data = {
type: "input",
value: value,
// 其它你想要收集的信息
};

// 发送数据
sendData(data);
});

或者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Button.tsx - 按钮组件
import React from "react";
import { trackEvent } from "./track"; // 引入埋点模块

const Button = () => {
const handleClick = () => {
trackEvent("button_click", {
buttonText: "Click Me", // 记录按钮的文本
buttonId: "btn-123", // 按钮的ID(或其他标识符)
page: window.location.pathname, // 当前页面路径
});
alert("Button clicked!");
};

return (
<button id="btn-123" onClick={handleClick}>
Click Me
</button>
);
};

export default Button;

页面浏览埋点

记录用户访问某个页面时的相关信息,如页面路径、页面标题等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// App.tsx - 页面组件
import React, { useEffect } from "react";
import { trackEvent } from "./track"; // 引入埋点模块

const App = () => {
useEffect(() => {
// 页面加载时记录页面浏览事件
trackEvent("page_view", {
pagePath: window.location.pathname, // 页面路径
pageTitle: document.title, // 页面标题
});
}, []);

return (
<div>
<h1>Welcome to My App</h1>
<Button /> {/* 上面定义的按钮组件 */}
</div>
);
};

export default App;

浏览器 API

性能、运行监控

为了帮助开发者更好地衡量和改进前端页面的性能,W3C 性能小组引入了 Navigation Timing API ,实现了自动、精准的页面性能打点,开发者可以通过 window.performance 属性获取:

  • performance.timing:定义了从 navigationStartloadEventEnd 的 21 个只读属性
  • performance.navigation:定义了当前文档的导航信息,比如是重载还是向前向后
  • performance.memory:内存监控(仅 Chrome)

Navigation Timing API 含义
Navigation Timing API 分析

  • 起始点
    页面性能统计的起始点时间,应该是用户输入网址回车后开始等待的时间。一个是通过 navigationStart 获取,相当于在 URL 输入栏回车或者页面按 F5 刷新的时间点;另外一个是通过 fetchStart,相当于浏览器准备好使用 HTTP 请求获取文档的时间。

    从开发者实际分析使用的场景,浏览器重定向、卸载页面的耗时对页面加载分析并无太大作用;通常建议使用 fetchStart 作为统计起始点。

  • 首字节
    主文档返回第一个字节的时间,是页面加载性能比较重要的指标。对用户来说一般无感知,对于开发者来说,则代表访问网络后端的整体响应耗时。

  • 白屏时间
    用户看到页面展示出现一个元素的时间。很多人认为白屏时间是页面返回的首字节时间,但这样其实并不精确,因为头部资源还没加载完毕,页面也是白屏。

    相对来说,具备「白屏时间」统计意义的指标,可取 domLoading - fetchStart,此时页面开始解析 DOM 树,页面渲染的第一个元素会很快出现。从 Navigation Timing Level 2 的方案设计,可以直接采用 domInteractive - fetchStart ,此时页面资源加载完成即将进入渲染环节。

  • 首屏时间
    首屏时间是指页面第一屏所有资源完整展示的时间。这是一个对用户来说非常直接的体验指标,但是对于前端却是一个非常难以统计衡量的指标。

    具备一定意义上的指标可以使用 domContentLoadedEventEnd - fetchStart,甚至使用 loadEventStart - fetchStart,此时页面 DOM 树已经解析完成并且显示内容。

统计页面性能指标的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// 定义一个发送数据的函数
function sendData(data) {
console.log("我才不要每次都触发呢", data);
setTimeout(() => {
// 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
// 例如:
// fetch('/api/track', {
// method: 'POST',
// body: JSON.stringify(data),
// headers: {
// 'Content-Type': 'application/json'
// }
// });
}, 10000);
}

// 监听页面加载事件
window.addEventListener("load", function () {
// 获取性能数据
const [performanceData] = performance.getEntriesByType("navigation");
// 即将废弃 推荐上面的PerformanceNavigationTiming写法
// let performanceData = window.performance.timing;

// 计算页面加载时间(window.performance.timing使用这个)
// let pageLoadTime = performanceData.domContentLoadedEventEnd - performanceData.navigationStart;
// 计算页面加载时间(performance.getEntriesByType("navigation")的时候使用这个)
let pageLoadTime = performanceData.loadEventEnd - performanceData.domComplete;

// 计算请求响应时间
const requestResponseTime =
performanceData.responseEnd - performanceData.requestStart;

// 计算DNS查询时间
let dnsLookupTime =
performanceData.domainLookupEnd - performanceData.domainLookupStart;

// 计算TCP连接时间
let tcpConnectTime =
performanceData.connectEnd - performanceData.connectStart;

// 重定向时间
const redirectTime =
performanceData.redirectEnd - performanceData.redirectStart;

// TTFB 读取页面第一个字节的时间
const ttfbTime =
performanceData.responseStart - performanceData.navigationStart;

// DNS 缓存时间
const appcacheTime =
performanceData.domainLookupStart - performanceData.fetchStart;

// 卸载页面的时间
const unloadTime =
performanceData.unloadEventEnd - performanceData.unloadEventStart;

// request请求耗时
const reqTime = performanceData.responseEnd - performanceData.responseStart;

// 解析dom树耗时
const analysisTime =
performanceData.domComplete - performanceData.domInteractive;

// 白屏时间
const blankTime =
(performanceData.domInteractive || performanceData.domLoading) -
performanceData.fetchStart;

// domReadyTime
const domReadyTime =
performanceData.domContentLoadedEventEnd - performanceData.fetchStart;

// 获取 FCP 时间
let fcpTime = 0;
const [fcpEntry] = performance.getEntriesByName("first-contentful-paint");
if (fcpEntry) {
fcpTime = fcpEntry.startTime;
}

// 获取 LCP 时间
let lcpTime = 0;
const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
if (lcpEntries.length > 0) {
lcpTime =
lcpEntries[lcpEntries.length - 1].renderTime ||
lcpEntries[lcpEntries.length - 1].loadTime;
}

// Paint Timing
const paintMetrics = performance.getEntriesByType("paint");
paintMetrics.forEach((metric) => {
console.log(metric.name + ": " + metric.startTime + "ms");
});

// 监听长任务
let tti = 0;
let tbt = 0;
const observer = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// 计算 TBT
if (entry.duration > 50) {
tbt += entry.duration - 50;
}
}

// 计算 TTI
if (tti === 0 && tbt < 50) {
tti = performance.now();
}
});
observer.observe({ entryTypes: ["longtask"] });

// 构造要发送的性能数据
let perfData = {
type: "performance",
pageLoadTime,
dnsLookupTime,
tcpConnectTime,
blankTime,
requestResponseTime,
tbt,
tti,
// 其它收集的信息
};

// 发送性能数据
sendData(perfData);
});

performance.getEntriesByType("navigation") 等同于 window.performance.timing,并且这里获取到的 performanceData 主要用于测量浏览器加载 HTML 文档所需的时间,而如果是 performance.getEntriesByType("resource") 值的是获取资源(css,脚本,图片等)的时间。

web-vitals

Navigation Timing API 可以监控大部分前端页面的性能。但是随着 SPA 模式的盛行以及框架的普及,页面内容渲染的时机改变了,W3C 标准无法完全满足监控意义。通过 window.performance.timing 所获的的页面渲染所相关的数据,在 SPA 应用中改变了 url 但是不刷新页面的情况下是不会更新的。因此仅仅通过该 API 是无法获得每一个子路由所对应页面的渲染时间。如果需要上报切换路由情况下每一个子页面重新 render 的时间,需要自定义上报。

幸运的是目前 W3C 关于首屏统计已经进入了提议阶段,以 Chrome 为首的浏览器正在打造更能代表用户使用体验的 FP、FCP、FMP 指标,并且逐步开放 API,如谷歌的 web-vitals 插件。

异常监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function sendData(data) {
console.log("我才不要每次都触发呢", data);
setTimeout(() => {
// 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
// 例如:
// fetch('/api/track', {
// method: 'POST',
// body: JSON.stringify(data),
// headers: {
// 'Content-Type': 'application/json'
// }
// });
}, 10000);
}

// 监听错误事件
window.addEventListener("error", function (event) {
// 获取错误信息
let message = event.message;
let filename = event.filename;
let lineno = event.lineno;
let colno = event.colno;

// 构造要发送的数据
let data = {
type: "error",
message: message,
filename: filename,
lineno: lineno,
colno: colno,
// 其它你想要收集的信息
};

// 发送数据
sendData(data);
});

资源监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
function sendData(data) {
console.log("我才不要每次都触发呢", data);
// 在这里,你可以使用AJAX、Fetch或其他方法将数据发送到服务器
// 例如:
// fetch('/api/track', {
// method: 'POST',
// body: JSON.stringify(data),
// headers: {
// 'Content-Type': 'application/json'
// }
// });
}

window.addEventListener("load", function () {
// 获取资源性能数据
let resourceData = performance.getEntriesByType("resource");

// 遍历资源数据
resourceData.forEach(function (resource) {
// 获取资源的相关信息,例如名称、类型、大小等
let name = resource.name;
let type = resource.initiatorType;
let size = resource.transferSize;

// 可计算的资源时间
console.log(`== 资源 [${i}] - ${resource.name}`);
// 重定向时间
let t = resource.redirectEnd - resource.redirectStart;
console.log(`… 重定向时间 = ${t}`);

// DNS时间
t = resource.domainLookupEnd - resource.domainLookupStart;
console.log(`… DNS查询时间 = ${t}`);

// TCP握手时间
t = resource.connectEnd - resource.connectStart;
console.log(`… TCP握手时间 = ${t}`);

// 响应时间
t = resource.responseEnd - resource.responseStart;
console.log(`… 响应时间 = ${t}`);

// 获取直到响应结束
t =
resource.fetchStart > 0
? resource.responseEnd - resource.fetchStart
: "0";
console.log(`… 获取直到响应结束时间 = ${t}`);

// 请求开始直到响应结束
t =
resource.requestStart > 0
? resource.responseEnd - resource.requestStart
: "0";
console.log(`… 请求开始直到响应结束时间 = ${t}`);

// 开始直到响应结束
t =
resource.startTime > 0 ? resource.responseEnd - resource.startTime : "0";
console.log(`… 开始直到响应结束时间 = ${t}`);
});
// 构造要发送的资源数据
let resData = {
type: "resource",
name: name,
resourceType: type,
size: size,
// 其它你想要收集的信息
};

// 发送资源数据
sendData(resData);
});

数据上报

测量好时间后,需要将数据发送给服务端。页面性能统计数据对丢失率要求比较低,且性能统计应该在尽量不影响主流程的逻辑和页面性能的前提下进行。

方式 是否异步 页面关闭时是否可靠 是否阻塞主线程 常见问题
fetch() ❌ 可能丢失 页面关闭时中断
<img src> 打点 ⚠️ 不完全可靠 容易被拦截
sendBeacon() ✅ 最可靠 ❌ 不阻塞 推荐用于监控

上报方式

当浏览器支持 sendBeacon 方法,优先使用该方法,其次使用 img 标签请求降级上报。

sendBeacon

大部分现代浏览器都支持 navigator.sendBeacon 方法,可以用来发送一些统计和诊断的小量数据,特别适合上报统计、监控数据、日志、埋点的场景。有如下优点:

  • 数据可靠,浏览器关闭请求也照样能发,无需保持连接(自动后台发送)
  • 异步且不会阻塞页面卸载(比如用户关闭页面、跳转时依然能发送),不会影响下一页面的加载
  • 不影响性能(浏览器会在空闲时发送)
  • 默认使用 POST 请求
  • API 使用简单

使用方法:

1
navigator.sendBeacon(url, data);
  • url:要上报的接口地址(通常是后端收集日志的 API)
  • data:要上报的数据(可以是 Blob、FormData、URLSearchParams、或 string)
  • 返回值:true 表示已加入发送队列(不保证服务器收到),false 表示发送失败。

使用实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function reportPerformance(data) {
const url = "https://monitor.example.com/report";

// 一般传JSON字符串
const body = JSON.stringify({
type: "performance",
time: Date.now(),
metrics: data,
});

// sendBeacon 默认 Content-Type: text/plain;charset=UTF-8
navigator.sendBeacon(url, body);
}

// 例如页面卸载时上报
window.addEventListener("unload", () => {
const metrics = {
lcp: 2.3,
fid: 50,
cls: 0.01,
};
reportPerformance(metrics);
});

实践建议:

  • 数据量小于 64KB(浏览器限制,超过可能失败)
  • POST JSON 字符串即可,不需设置额外 header
  • 不适合返回结果(它不能拿到响应)
  • 页面卸载时配合 visibilitychange 事件(比 unload 更可靠,现代浏览器推荐用 visibilitychange)
    1
    2
    3
    4
    5
    window.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") {
    navigator.sendBeacon("/report", JSON.stringify(data));
    }
    });

img 标签打点

  • 不存在 AJAX 跨域问题,可做跨源的请求
  • 很古老的标签,没有浏览器兼容性问题
1
2
3
4
5
6
7
8
9
10
var i = new Image();

i.onload =
i.onerror =
i.onabort =
function () {
i = i.onload = i.onerror = i.onabort = null;
};

i.src = url;

上报策略

  • 批量上报(减少请求数)
  • 异步,对性能无影响
  • 离线缓存(localStorage / IndexedDB,网络恢复再上报)

存储分析

  • 后端存储日志(如 ELK、ClickHouse、InfluxDB)
  • 监控平台(Grafana、Datadog、Prometheus、自建大屏)
  • 指标对比
    • 各版本之间(是否优化成功)
    • 不同地区、不同设备、不同网络的对比
    • 用户分层(高价值用户 vs 普通用户)

报警机制

  • 阈值报警(比如 LCP > 4s 占比超过 20%)
  • 趋势报警(性能逐渐变差)
  • 错误率报警(API、JS 错误飙升)

前端监控
https://xuekeven.github.io/2025/10/04/前端监控/
作者
Keven
发布于
2025年10月4日
更新于
2025年10月5日
许可协议