性能监控深度解析
一句话概括
性能监控是前端工程化质量保障体系的核心支柱,通过采集 FP、FCP、LCP、CLS、INP 等关键指标,结合 Performance API 与自定义测速点,实现对页面加载、交互响应和视觉稳定性的全面量化。本文从浏览器底层渲染管道出发,系统拆解 Web Vitals 标准、Performance API 能力图谱、自定义测速埋点方案,并给出完整的生产级监控 SDK 实现与面试必备考点。
背景与意义
性能是用户体验最直接的映射。Google 在 2020 年将 Core Web Vitals 纳入搜索排名算法后,”快”不再只是开发者的自我追求,而是直接影响产品流量的商业指标。根据 Akamai 的研究,页面加载延迟 100ms 会导致转化率下降 7%。在面试中,性能监控相关问题的出现频率高达 80% 以上,尤其在一线大厂的二面和三面中,面试官几乎必然会问:”你们的项目做了哪些性能监控?如何实现首屏加载的精确上报?LCP 的测量原理是什么?” 这些问题的本质,是考察候选人是否具备从”能跑”到”能度量”的工程化认知。
概念与定义
FP (First Paint)
浏览器首次将任何像素绘制到屏幕的时间点。FP 标志着页面开始渲染,是所有性能指标中最先触发的。
FCP (First Contentful Paint)
浏览器首次绘制任何 DOM 内容(文本、图像、Canvas)的时间点。FCP 之后用户才能看到”有意义的”画面。
LCP (Largest Contentful Paint)
视口中最大可见内容元素完成渲染的时间。LCP 衡量”页面看起来是否加载完成”,优质标准为 ≤2.5s。
CLS (Cumulative Layout Shift)
累计布局偏移,量化页面加载过程中元素位置的意外移动。优质标准为 ≤0.1。
INP (Interaction to Next Paint)
衡量页面交互响应速度的指标,记录从用户交互到下次绘制的时间。2024 年 3 月起正式取代 FID 成为 Core Web Vitals 指标。优质标准为 ≤200ms。
Performance API
W3C 标准化的一组浏览器 API,提供 performance.timing、performance.getEntriesByType()、PerformanceObserver 等能力,是前端性能监控的底层基础设施。
核心知识点拆解
1. Web Vitals 指标的精确采集
LCP 并非一个静态值——它在页面加载过程中会随着更大元素的出现而不断更新,直到用户产生首次交互或页面隐藏。因此必须使用 PerformanceObserver 来监听动态变化:
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
// LCP 精确采集 - 必须使用 PerformanceObserver 而非静态 timing API
const lcpEntries = [];
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
// LCP 在用户交互之前会持续更新,取最后一个 entry 作为最终值
entries.forEach((entry) => {
lcpEntries.push(entry);
});
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// 在页面隐藏时上报最终 LCP
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && lcpEntries.length > 0) {
const finalLCP = lcpEntries[lcpEntries.length - 1];
// 过滤掉无效值
if (finalLCP.startTime > 0 && finalLCP.startTime < 60000) {
reportMetric('LCP', finalLCP.startTime, {
element: finalLCP.element?.tagName || 'unknown',
url: finalLCP.url || '',
size: finalLCP.size || 0,
});
}
lcpObserver.disconnect();
}
}, { once: true });
CLS 的采集略有不同,它是一个”累加值”而非”时间点”,需要汇总所有意外的布局偏移:
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
// CLS 累计算法 - 需要合理设置会话窗口
let clsValue = 0;
let clsEntries = [];
let sessionStart = performance.now();
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 只统计没有用户交互时的布局偏移
if (!entry.hadRecentInput) {
// 会话窗口策略:如果两次偏移间隔超过 1s 或窗口累计超过 5s,开启新会话
const currentTime = performance.now();
if (currentTime - sessionStart > 5000 ||
clsEntries.length === 0) {
// 上报上一个会话
if (clsEntries.length > 0) {
reportMetric('CLS', clsValue, {
entries: clsEntries.length,
});
}
// 重置会话
clsValue = 0;
clsEntries = [];
sessionStart = currentTime;
}
clsValue += entry.value;
clsEntries.push(entry);
}
}
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
// 页面卸载前上报最终 CLS
window.addEventListener('beforeunload', () => {
if (clsValue > 0) {
reportMetric('CLS', clsValue.toFixed(3));
}
});
2. Performance API 深度使用
performance 对象提供了多层级的性能数据。除了常用的 PerformanceObserver,performance.getEntriesByType() 可以直接获取特定类型的所有性能条目:
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
// 全面采集性能条目 - 区分不同资源类型
function collectPerformanceEntries() {
const metrics = {};
// 1. Navigation Timing - 页面加载关键时间点
const [navEntry] = performance.getEntriesByType('navigation');
if (navEntry) {
metrics.domContentLoaded = navEntry.domContentLoadedEventEnd - navEntry.domContentLoadedEventStart;
metrics.loadEvent = navEntry.loadEventEnd - navEntry.loadEventStart;
metrics.domInteractive = navEntry.domInteractive;
metrics.redirectCount = navEntry.redirectCount;
// 网络耗时拆解
metrics.dnsLookup = navEntry.domainLookupEnd - navEntry.domainLookupStart;
metrics.tcpConnection = navEntry.connectEnd - navEntry.connectStart;
metrics.tlsNegotiation = navEntry.secureConnectionStart > 0
? navEntry.connectEnd - navEntry.secureConnectionStart : 0;
metrics.requestToResponse = navEntry.responseStart - navEntry.requestStart;
metrics.contentDownload = navEntry.responseEnd - navEntry.responseStart;
}
// 2. Resource Timing - 所有资源的加载详情
const resources = performance.getEntriesByType('resource');
metrics.totalResources = resources.length;
metrics.totalTransferSize = resources.reduce((sum, r) => sum + (r.transferSize || 0), 0);
metrics.totalEncodedSize = resources.reduce((sum, r) => sum + (r.encodedBodySize || 0), 0);
// 3. 按资源类型汇总耗时
const byType = { script: [], css: [], img: [], fetch: [], other: [] };
resources.forEach((r) => {
if (r.name.endsWith('.js') || r.initiatorType === 'script') byType.script.push(r.duration);
else if (r.name.endsWith('.css') || r.initiatorType === 'link') byType.css.push(r.duration);
else if (r.initiatorType === 'img') byType.img.push(r.duration);
else if (r.initiatorType === 'fetch' || r.initiatorType === 'xmlhttprequest') byType.fetch.push(r.duration);
else byType.other.push(r.duration);
});
Object.entries(byType).forEach(([key, durations]) => {
if (durations.length > 0) {
metrics[`${key}Count`] = durations.length;
metrics[`${key}TotalTime`] = durations.reduce((a, b) => a + b, 0).toFixed(2);
}
});
// 4. Long Tasks - 长任务监听(阻塞主线程的任务)
metrics.longTasks = [];
const ltObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
metrics.longTasks.push({
duration: entry.duration,
startTime: entry.startTime,
// attribution 提供更详细的归因信息
attribution: entry.attribution?.map(a => ({
name: a.name,
containerType: a.containerType,
containerId: a.containerId,
containerSrc: a.containerSrc,
})),
});
}
});
ltObserver.observe({ type: 'longtask', buffered: true });
return metrics;
}
// 调用并上报
const perfData = collectPerformanceEntries();
reportMetric('performanceEntries', perfData);
3. 自定义测速埋点体系
除了浏览器原生指标,业务场景往往需要自定义测速点——比如”用户看到商品列表的时间”、”搜索请求到首条结果渲染的时间”。自定义测速通过打标记(mark)和测量(measure)实现:
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
129
130
131
132
133
134
135
// 自定义测速 SDK 核心实现
class PerformanceMarkSDK {
constructor(options = {}) {
this.appName = options.appName || 'default';
this.marks = new Map();
this.measures = new Map();
this.isRecording = options.autoRecord !== false;
}
// 打点标记
mark(name, detail = {}) {
if (!this.isRecording) return;
const entry = {
name: `${this.appName}:${name}`,
timestamp: performance.now(),
detail,
};
// 使用 Performance API 原生 mark 以兼容 DevTools
if (window.performance && window.performance.mark) {
performance.mark(entry.name, { detail });
}
this.marks.set(name, entry);
return entry;
}
// 测量两个标记之间的耗时
measure(name, startMark, endMark) {
if (!this.isRecording) return null;
const start = this.marks.get(startMark);
const end = this.marks.get(endMark);
if (!start || !end) {
console.warn(`[PerformanceMarkSDK] 标记 ${startMark} 或 ${endMark} 不存在`);
return null;
}
const duration = end.timestamp - start.timestamp;
const measureEntry = {
name: `${this.appName}:${name}`,
duration,
startMark,
endMark,
startTime: start.timestamp,
endTime: end.timestamp,
};
// 使用 Performance API 原生 measure
if (window.performance && window.performance.measure) {
try {
performance.measure(`${this.appName}:${name}`,
`${this.appName}:${startMark}`,
`${this.appName}:${endMark}`);
} catch (e) {
// 忽略标记已被清除的情况
}
}
this.measures.set(name, measureEntry);
return measureEntry;
}
// 测量从页面打开到指定标记的时间
measureFromStart(name, endMark) {
const end = this.marks.get(endMark);
if (!end) return null;
const duration = end.timestamp;
const entry = { name, duration, startMark: 'pageStart', endMark };
this.measures.set(`fromStart:${name}`, entry);
return entry;
}
// 批量上报
flush() {
const measures = Array.from(this.measures.values());
const marks = Array.from(this.marks.values());
if (measures.length === 0 && marks.length === 0) return;
// 压缩数据,只保留必要字段
const payload = {
appName: this.appName,
measures: measures.map(m => ({
n: m.name,
d: Math.round(m.duration),
s: m.startMark,
e: m.endMark,
})),
marks: marks.map(m => ({
n: m.name,
t: Math.round(m.timestamp),
})),
timestamp: Date.now(),
};
// 发送到后端
this.send(payload);
// 发送后清空,避免重复上报
this.measures.clear();
return payload;
}
// 发送逻辑(可替换为实际上报实现)
send(data) {
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/metrics', JSON.stringify(data));
} else {
const img = new Image();
img.src = `/api/metrics?data=${encodeURIComponent(JSON.stringify(data))}`;
}
}
}
// 使用示例
const perfSDK = new PerformanceMarkSDK({ appName: 'mall' });
// 用户点击搜索按钮时
perfSDK.mark('search:start', { keyword: '手机' });
// 搜索结果数据回来后
perfSDK.mark('search:apiDone', { resultsCount: 30 });
// 首条结果渲染后
perfSDK.mark('search:firstResultRendered', { resultIndex: 0 });
// 测量搜索耗时
const apiDuration = perfSDK.measure('search:api', 'search:start', 'search:apiDone');
const firstResultDuration = perfSDK.measure('search:toFirstResult', 'search:start', 'search:firstResultRendered');
// 某个合适时机批量上报
perfSDK.flush();
4. 性能数据的去重与采样策略
生产环境中,对每一个用户都采集全量指标是不现实的——不仅消耗带宽,还可能影响性能本身。因此需要智能的采样和去重策略:
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
// 智能采样与去重引擎
class PerformanceSampler {
constructor(options = {}) {
this.sampleRate = options.sampleRate || 0.1; // 默认 10% 采样
this.maxMetricsPerSession = options.maxMetricsPerSession || 50;
this.metricCount = 0;
this.seenHashes = new Set();
this.sessionId = this.generateSessionId();
this.userId = this.getUserId();
}
// 判断是否应该采集本次数据
shouldSample(metricName) {
// 1. 强制采集关键指标
const criticalMetrics = ['LCP', 'FCP', 'CLS', 'INP'];
if (criticalMetrics.includes(metricName)) {
return this.shouldSampleByRate(0.5); // 关键指标 50% 采样
}
// 2. 超限保护
if (this.metricCount >= this.maxMetricsPerSession) {
return false;
}
// 3. 按采样率随机
return this.shouldSampleByRate(this.sampleRate);
}
// 数据去重(基于内容的哈希)
deduplicate(metricPayload) {
const hash = this.calculateHash(metricPayload);
if (this.seenHashes.has(hash)) {
return false; // 已上报过相同数据
}
this.seenHashes.add(hash);
return true;
}
// 采样率随机判断
shouldSampleByRate(rate) {
// 基于用户 ID 的稳定采样:同一用户始终被采或始终不被采(一致性哈希思想)
const userIdHash = this.hashString(this.userId);
return (userIdHash % 100) < (rate * 100);
}
calculateHash(obj) {
const str = JSON.stringify(obj, Object.keys(obj).sort());
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 32-bit integer
}
return Math.abs(hash).toString(36);
}
hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
}
return Math.abs(hash);
}
generateSessionId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
getUserId() {
// 从 cookie 或 localStorage 获取用户标识
try {
const uid = localStorage.getItem('user_id') ||
document.cookie.match(/user_id=([^;]+)/)?.[1] ||
this.generateAnonymousId();
return uid;
} catch {
return this.generateAnonymousId();
}
}
generateAnonymousId() {
const id = `anon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
localStorage.setItem('user_id', id);
} catch {}
return id;
}
}
实战案例
以下是一个完整的生产级性能监控 SDK 雏形,整合了 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
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
// ProMon - 生产级性能监控 SDK
(function (global) {
'use strict';
class ProMon {
constructor(config) {
this.config = Object.assign({
appName: 'unknown',
reportUrl: '/api/metrics',
sampleRate: 0.1,
criticalSampleRate: 0.5,
maxMetricsPerBatch: 20,
reportInterval: 10000, // 每 10s 批量上报一次
debug: false,
}, config);
this.metrics = [];
this.marks = {};
this.startTime = performance.now();
this.observerInstances = [];
this.init();
}
init() {
this.log('[ProMon] 初始化性能监控...');
// 采集基础环境信息
this.envData = {
url: location.href,
ua: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
devicePixelRatio: window.devicePixelRatio,
connection: navigator.connection?.effectiveType || 'unknown',
timestamp: Date.now(),
};
// 采集 Web Vitals
this.observeLCP();
this.observeCLS();
this.observeINP();
this.observeFID();
this.observeLongTasks();
// 采集 FP / FCP(通过 Paint Timing API)
this.observePaintTiming();
// 采集 DOM Content Loaded 和 Load 事件
this.captureTimingEvents();
// 启动定时上报
this.reportTimer = setInterval(() => this.flush(), this.config.reportInterval);
// 页面卸载时立即上报
window.addEventListener('beforeunload', () => this.flush());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
}
// --- Web Vitals 采集 ---
observePaintTiming() {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint') {
this.pushMetric('FP', entry.startTime, { entryType: entry.entryType });
} else if (entry.name === 'first-contentful-paint') {
this.pushMetric('FCP', entry.startTime, { entryType: entry.entryType });
}
}
});
observer.observe({ type: 'paint', buffered: true });
this.observerInstances.push(observer);
} catch (e) {
this.log('[ProMon] Paint Timing 不支持', e);
}
}
observeLCP() {
try {
let lcpValue = 0;
let lcpEntry = null;
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
const last = entries[entries.length - 1];
lcpValue = last.startTime;
lcpEntry = {
element: last.element?.tagName || 'unknown',
id: last.element?.id || '',
classes: last.element?.className?.slice(0, 100) || '',
url: last.url || '',
size: last.size || 0,
};
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
this.observerInstances.push(observer);
// 在页面隐藏时上报最终 LCP
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && lcpValue > 0) {
this.pushMetric('LCP', lcpValue, lcpEntry);
}
}, { once: true });
} catch (e) {
this.log('[ProMon] LCP 不支持', e);
}
}
observeCLS() {
try {
let clsValue = 0;
let sessionScore = 0;
let sessionEntries = 0;
let sessionStart = performance.now();
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
// 会话窗口逻辑
const now = performance.now();
if (now - sessionStart > 5000 && sessionEntries > 0) {
clsValue += sessionScore;
sessionScore = 0;
sessionEntries = 0;
sessionStart = now;
}
sessionScore += entry.value;
sessionEntries++;
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
this.observerInstances.push(observer);
window.addEventListener('beforeunload', () => {
clsValue += sessionScore;
if (clsValue > 0) {
this.pushMetric('CLS', parseFloat(clsValue.toFixed(4)));
}
});
} catch (e) {
this.log('[ProMon] CLS 不支持', e);
}
}
observeINP() {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.pushMetric('INP', entry.duration, {
interactionType: entry.name,
interactionTarget: entry.interactionId || '',
});
}
});
observer.observe({ type: 'first-input', buffered: true });
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
this.observerInstances.push(observer);
} catch (e) {
this.log('[ProMon] INP 不支持', e);
}
}
observeFID() {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.pushMetric('FID', entry.processingStart - entry.startTime, {
target: entry.target?.tagName || 'unknown',
type: entry.name,
});
}
});
observer.observe({ type: 'first-input', buffered: true });
this.observerInstances.push(observer);
} catch (e) {
this.log('[ProMon] FID 不支持', e);
}
}
observeLongTasks() {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 100) {
this.pushMetric('LONG_TASK', entry.duration, {
startTime: Math.round(entry.startTime),
attribution: entry.attribution?.[0]?.containerType || 'unknown',
});
}
}
});
observer.observe({ type: 'longtask', buffered: true });
this.observerInstances.push(observer);
} catch (e) {
this.log('[ProMon] Long Tasks 不支持', e);
}
}
// --- 自定义测速 ---
mark(name, detail) {
this.marks[name] = {
name: `${this.config.appName}:${name}`,
time: performance.now(),
detail: detail || {},
};
try {
performance.mark(`${this.config.appName}:${name}`, { detail });
} catch (e) {}
}
measure(name, startMark, endMark) {
const start = this.marks[startMark];
const end = this.marks[endMark];
if (!start || !end) {
this.log(`[ProMon] measure "${name}" 失败: 标记 "${startMark}" 或 "${endMark}" 不存在`);
return;
}
const duration = end.time - start.time;
this.pushMetric(`CUSTOM_${name}`, duration, {
startMark,
endMark,
});
}
// --- 事件捕获 ---
captureTimingEvents() {
// DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
this.pushMetric('DOM_CONTENT_LOADED', performance.now() - this.startTime);
}, { once: true });
// Load
window.addEventListener('load', () => {
this.pushMetric('WINDOW_LOAD', performance.now() - this.startTime);
}, { once: true });
}
// --- 指标存储与上报 ---
pushMetric(name, value, extras) {
if (!this.shouldSample(name)) return;
// 值有效性检查
if (typeof value !== 'number' || !isFinite(value) || value < 0 || value > 3600000) {
return;
}
const metric = {
n: name,
v: Math.round(value * 100) / 100,
t: Date.now(),
s: this.sessionTime(),
};
if (extras) {
metric.e = extras;
}
this.metrics.push(metric);
this.log(`[ProMon] 📊 ${name} = ${metric.v}ms`, extras);
}
sessionTime() {
return Math.round(performance.now());
}
shouldSample(name) {
const critical = ['LCP', 'FCP', 'FP', 'CLS', 'INP', 'FID'];
const rate = critical.includes(name)
? this.config.criticalSampleRate
: this.config.sampleRate;
if (rate >= 1) return true;
return Math.random() < rate;
}
flush() {
if (this.metrics.length === 0) return;
const batch = this.metrics.splice(0, this.config.maxMetricsPerBatch);
const payload = {
env: this.envData,
metrics: batch,
total: this.metrics.length,
};
this.send(payload);
}
send(data) {
const body = JSON.stringify(data);
const url = this.config.reportUrl;
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
} else {
// fallback: 使用 fetch with keepalive
fetch(url, {
method: 'POST',
body,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
}).catch(() => {});
}
}
log(...args) {
if (this.config.debug) {
console.log(...args);
}
}
}
global.ProMon = ProMon;
})(window);
// 使用方式
const monitor = new ProMon({
appName: 'ecommerce',
reportUrl: 'https://monitor.example.com/v1/metrics',
sampleRate: 0.1,
debug: true,
});
底层原理
浏览器渲染管道与性能指标的对应关系
要理解性能监控的本质,必须回到浏览器渲染管道的底层。一个完整帧的渲染管线为:
1
JavaScript → Style → Layout → Paint → Composite
- FP 触发于 Paint 阶段完成——当浏览器将任何像素绘制到帧缓冲区时。在此之前,如果只有 DOM 解析而没有任何渲染输出,FP 不会触发。
- FCP 触发于首次包含有意义内容的 Paint。在底层,浏览器的事件循环会在每一帧结束时检查 “paint 是否包含内容节点”。Chrome 源码中通过
PaintTimingDetector类来追踪——每当帧提交时,如果绘制命令列表中包含文本或图像相关的绘制操作,就会记录 FCP 时间。 - LCP 基于更新策略:在每次 Layout 完成后,浏览器计算可见区域中面积最大的候选元素,当候选元素比前一个更大时,更新 LCP 值。Chrome 源码中
LargestContentfulPaintCandidate类通过比较元素的intrinsic size(固有尺寸)/visual size(视觉尺寸)/intersection ratio(交叉比)来确定最大元素。最终 LCP 在PageHidden时冻结。 - CLS 的测量利用了
LayoutShiftTracker。每次 layout 发生变更后,Chrome 计算所有受影响元素的”移动距离 × 移动区域”的加权和。该值除以视窗面积即为单次 Layout Shift 的得分。hadRecentInput标记通过追踪最后 500ms 内的 Pointer 事件来实现。
PerformanceObserver 的回调时机
PerformanceObserver 的回调在微任务队列执行,这意味着在同一轮事件循环中,样式计算和布局已经完成,但宏任务队列中的其他任务尚未执行。这保证了观测到的 Timing 数据是”当前帧”的快照。
sendBeacon 的底层行为
navigator.sendBeacon() 之所以被推荐用于上报,是因为它不受页面卸载的影响。底层实现中,浏览器会持有一个未完成的 HTTP 请求队列,即使页面上下文已经被销毁,队列中的请求仍会被发送。相比之下,XMLHttpRequest 和 fetch 在页面卸载阶段可能会被取消。
高频面试题解析
面试题 1:LCP 的测量原理是什么?为什么不能直接用 performance.timing 获取?
答案要点: LCP 并非一个固定时间点——它随着更大元素的出现而更新,直到用户交互或页面隐藏。performance.timing 只记录导航事件的静态时间点,无法捕获渲染过程中的动态更新。LCP 通过 PerformanceObserver 监听 largest-contentful-paint 类型来实时追踪。Chrome 内部维护一个候选元素队列,每次布局完成后检查是否有更大元素出现,有则更新。最终的上报时机在 visibilitychange → hidden 时。
面试题 2:为什么要用会话窗口来计算 CLS?直接累加不行吗?
答案要点: 直接累加会导致长时间打开的页面 CLS 无限增大,无法反映页面加载时的真实稳定性。Google 推荐的”会话窗口”策略将 CLS 分为多个 ≤5s 的窗口,窗口之间间隔 ≥1s,最终取所有窗口得分的最大值。这模拟了用户”多次浏览”的场景——每次重新关注页面时重置布局偏移的计数。
面试题 3:PerformanceObserver 和 performance.getEntries() 有什么本质区别?
答案要点: getEntries() 返回的是”截至目前”的静态快照,后续触发的条目不会自动追加。PerformanceObserver 则采用订阅模式注册回调,任何新的性能条目产生时都会异步通知监听器。对于 LCP、CLS 这类动态更新的指标,必须使用 PerformanceObserver。此外,getEntries() 受浏览器 Buffer Size 限制(通常 150-250 条),超出后会丢失早期条目。
面试题 4:如何确保性能数据在页面卸载时可靠上报?
答案要点: 最可靠的方式是 navigator.sendBeacon(),它保证数据在页面卸载后仍会被发送。当 sendBeacon 不可用时,可降级为同步 XHR(async: false),但同步 XHR 已被废弃。还可以在 beforeunload 中设置一个空循环占用主线程足够长时间以等待网络请求发出,但这种方式对用户体验有负面影响。推荐策略:主通道用 sendBeacon,在 visibilitychange 的 hidden 状态触发上报,因为该状态比 beforeunload 更早触发且更可靠。
面试题 5:如何衡量 SPA 应用中的路由切换性能?
答案要点: SPA 的性能监控比 MPA 更复杂,因为浏览器不会自动触发生命周期事件。核心方案是:1)劫持路由库(如 React Router / Vue Router)的路由变更钩子;2)在路由切换开始时通过 performance.mark() 打点;3)使用 MutationObserver 侦测新视图的 DOM 渲染完成;4)结合 requestAnimationFrame 检测首帧渲染时间。关键指标包括:Route Switch Start → New DOM Ready 的耗时,以及新路由的 FCP(通过 PerformanceObserver 持续监听 Paint 事件)。
总结与扩展
知识体系图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
性能监控
├── Web Vitals(Core + Extra)
│ ├── LCP — 加载性能,目标 ≤2.5s
│ ├── INP — 交互性能,目标 ≤200ms
│ ├── CLS — 视觉稳定性,目标 ≤0.1
│ ├── FCP — 内容渲染,目标 ≤1.8s
│ └── TTFB — 服务器响应,目标 ≤800ms
├── Performance API
│ ├── Navigation Timing → 页面导航
│ ├── Resource Timing → 资源加载
│ ├── Paint Timing → FCP/FP
│ ├── Element Timing → 元素渲染
│ ├── Long Tasks → 主线程阻塞
│ └── Event Timing → 事件响应
├── 自定义测速
│ ├── performance.mark / measure
│ └── 业务层面的自定义打点
└── 数据上报与治理
├── sendBeacon / fetch keepalive
├── 采样与去重策略
└── 批量合并上报
延伸阅读
- Google 官方文档 - Web Vitals: https://web.dev/vitals/
- W3C Performance Timeline 规范: https://www.w3.org/TR/performance-timeline/
- Chrome 源码 - LargestContentfulPaint: chromium 搜索
PaintTimingDetector - web-vitals 库源码: GitHub 搜索
GoogleChrome/web-vitals— 生产级实现参考 - RUM 系统设计模式: 可参考
npm上的perfume.js、alife/logan等开源项目