前端性能监控深度解析
一句话概括
前端性能监控通过 Performance API 获取精准的性能时间线,以 Web Vitals(LCP/FID/CLS)为核心衡量指标,利用 PerformanceObserver 高效采集 RUM(Real User Monitoring)数据,帮助开发者客观度量、持续追踪、针对性优化用户体验。
1. 背景与意义
1.1 性能的”第二利润中心”效应
性能优化不是纯技术活——它有直接的经济价值。从多个行业头部公司的公开数据来看:
- Amazon:页面加载时间每增加 100ms,销售额下降 1%(Amazon 2009 年内部数据)
- Google:搜索结果页加载时间从 0.4s 增加到 0.9s,搜索流量和广告收入下降 20%
- Walmart:页面加载时间每减少 1s,转化率提升 2%
- BBC:页面加载时间每增加 1s,用户流失率增加 10%
- Pinterest:构建性能文化后,SEO 流量提升了 15%,注册用户增加了 15%
这些数据揭示了一个残酷的现实:在不被察觉的几百毫秒之内,用户已经在流失。
1.2 从 Synthetic 到 RUM:性能度量范式的转变
性能监控经历了两个阶段:
| 维度 | Synthetic Monitoring(合成监控) | RUM(真实用户监控) |
|---|---|---|
| 数据来源 | 模拟浏览器(Lighthouse、WebPageTest) | 真实用户访问 |
| 环境 | 固定网络/设备 | 各种网络/设备/浏览器 |
| 样本量 | 少量(每次跑几十次) | 大量(覆盖所有用户) |
| 典型工具 | Lighthouse CI, Sitespeed.io | Google Analytics, New Relic, Datadog |
| 适用场景 | 开发阶段回归检测 | 生产环境持续监控 |
| 问题发现 | ✅ 可发现问题 | ✅ 可发现问题 |
| 问题定位 | ✅ 有详细诊断 | ❌ 只有聚合指标 |
最佳实践是两者结合:Synthetic 用于 CI/CD 回归检测和深度诊断,RUM 用于生产环境用户体验衡量。
1.3 Web Vitals:Google 推动的行业标准化
2020 年 Google 推出 Web Vitals,并宣布将 Web Vitals 作为搜索排名因素(Page Experience Update)。这意味着:
- 性能直接影响网站的 SEO 自然流量
- 有了统一的衡量标准(不再各说各话)
- 有了明确的阈值(Good / Needs Improvement / Poor)
2. 概念与定义
2.1 Core Web Vitals 三大指标
LCP(Largest Contentful Paint,最大内容绘制)
定义:视口中最大的可见内容元素(图片、视频、文本块)完成渲染的时间。
1
2
3
4
5
6
LCP 时间线:
DOMContentLoaded ── LCP ──────────────────────────────────
│ │
│ 最大内容元素渲染
│
首次渲染 (FP) ── 首次内容渲染 (FCP) ── 最大内容渲染 (LCP)
| 等级 | LCP |
|---|---|
| ✅ 良好 | ≤ 2.5s |
| ⚠️ 待改善 | 2.5s ~ 4.0s |
| ❌ 较差 | > 4.0s |
LCP 元素通常是:
<img>元素<video>封面图- 设置了
background-image的块级元素 - 文本节点或内联 SVG
FID(First Input Delay,首次输入延迟)
定义:用户首次与页面交互(点击、按键)到浏览器开始处理事件回调的时间间隔。
1
2
3
4
5
用户点击 → [主线程繁忙] → 事件处理开始
│ │ │
│ FID │ │
└─────────────┘ │
[处理完成]
| 等级 | FID |
|---|---|
| ✅ 良好 | ≤ 100ms |
| ⚠️ 待改善 | 100ms ~ 300ms |
| ❌ 较差 | > 300ms |
关键点:FID 仅测量首次交互的输入延迟,不测量处理时间。
CLS(Cumulative Layout Shift,累积布局偏移)
定义:页面整个生命周期中所有非预期布局偏移的累积得分。
1
2
3
4
5
6
7
8
9
偏移计算:
┌───┬───┐ ┌───┬───┐
│ A │ B │ → │ B │ A │ ← 布局偏移
└───┴───┘ └───┴───┘
| 偏移比例: 已影响区域 / 视口总面积
| 距离比例: 元素移动距离 / 视口尺寸
|
└── 布局偏移分数 = 偏移比例 × 距离比例
| 等级 | CLS |
|---|---|
| ✅ 良好 | ≤ 0.1 |
| ⚠️ 待改善 | 0.1 ~ 0.25 |
| ❌ 较差 | > 0.25 |
常见的 CLS 罪魁祸首:
- 无尺寸的图片(未设置 width/height)
- 动态注入的广告
- 字体替换引起的布局偏移(FOUT/FOIT)
- 第三方嵌入(widget/iframe)
2.2 Performance API 概览
Performance API 的架构体系:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Performance (window.performance)
├── timing (PerformanceTiming) [已废弃,但兼容]
│ ├── navigationStart
│ ├── domContentLoadedEventEnd
│ └── loadEventEnd
│
├── navigation (PerformanceNavigation) [已废弃]
│
├── getEntries() (PerformanceObserver)
│ ├── "navigation" 导航计时
│ ├── "resource" 资源加载
│ ├── "paint" 首次渲染
│ ├── "first-input" 首次输入
│ ├── "largest-contentful-paint" LCP
│ ├── "layout-shift" 布局偏移
│ ├── "longtask" 长任务
│ └── "element" 元素级别
│
├── now() DOMHighResTimeStamp
├── mark(name) 自定义标记
├── measure(name, start, end) 自定义度量
└── clearMarks() 清理标记
2.3 PerformanceObserver 的设计哲学
PerformanceObserver 使用观察者模式替代旧的 performance.getEntries():
| 特性 | getEntries() | PerformanceObserver |
|---|---|---|
| 回调时机 | 一次性获取 | 持续监听新条目 |
| 性能 | 可能返回数千个条目 | 批量回调,按类型缓冲 |
| 异步 | 同步(可能阻塞) | 异步(不阻塞主线程) |
| 精确度 | 高 | 高 |
| 使用难度 | 简单 | 中等 |
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 旧的获取方式(同步)
const entries = performance.getEntriesByType('paint');
// 只能在页面加载后调用,无法获取历史数据
// ✅ 推荐的获取方式(异步观察者)
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
// 处理条目
});
});
observer.observe({ type: 'paint', buffered: true });
// buffered: true 可以获取 observer 注册前已经发生的事件
3. 最小示例
3.1 完整的 RUM 数据采集工具
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
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
// rum-collector.js
// 一个完整的前端 RUM 数据采集脚本,可嵌入到页面中
(function() {
'use strict';
/**
* RUMCollector - 真实用户监控数据采集器
*
* 采集的数据:
* - 导航时间(TTFB、DomContentLoaded、Load)
* - Web Vitals(LCP、FID/INP、CLS)
* - 设备/网络环境
* - 页面信息
*/
class RUMCollector {
constructor(options = {}) {
this.options = {
endpoint: options.endpoint || '/api/rum', // 数据上报地址
sampleRate: options.sampleRate || 0.1, // 采样率(10%)
maxCLSMeasurements: 100, // CLS 最大测量次数
autostart: true, // 自动启动
...options
};
this.metrics = {
pageLoad: {},
webVitals: {},
environment: this.collectEnvironment(),
pageInfo: this.collectPageInfo(),
timestamp: Date.now()
};
this.observers = [];
this.clsValue = 0;
this.clsEntries = [];
this.sessionId = this.generateSessionId();
if (this.options.autostart) {
this.init();
}
}
// 初始化采集
init() {
// 1. 采样率控制
if (Math.random() > this.options.sampleRate) {
console.log('[RUM] 未命中采样率,跳过');
return;
}
console.log('[RUM] 开始采集性能数据');
this.collectNavigationTiming();
this.observeWebVitals();
// 页面卸载时上报
if (typeof navigator.sendBeacon === 'function') {
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.report();
}
});
} else {
// 回退方案
window.addEventListener('beforeunload', () => this.report());
}
}
// 采集环境信息
collectEnvironment() {
const connection = navigator.connection ||
navigator.mozConnection ||
navigator.webkitConnection;
return {
connection: connection ? {
effectiveType: connection.effectiveType, // '4g' | '3g' | '2g' | 'slow-2g'
downlink: connection.downlink, // 带宽 Mbps
rtt: connection.rtt, // 往返延迟 ms
saveData: connection.saveData // 数据节省模式
} : null,
deviceMemory: navigator.deviceMemory || null, // 设备内存 GB
hardwareConcurrency: navigator.hardwareConcurrency || null, // CPU 核数
userAgent: navigator.userAgent,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
devicePixelRatio: window.devicePixelRatio
}
};
}
// 采集页面信息
collectPageInfo() {
return {
url: window.location.href,
referrer: document.referrer || '',
title: document.title,
pageId: this.generatePageId()
};
}
// 采集导航计时
collectNavigationTiming() {
// 等待 DOMContentLoaded 后获取
if (document.readyState === 'complete') {
this.processNavigationTiming();
} else {
window.addEventListener('load', () => {
setTimeout(() => this.processNavigationTiming(), 0);
});
}
}
processNavigationTiming() {
const nt = performance.getEntriesByType('navigation')[0];
if (!nt) {
this.metrics.pageLoad = {
note: 'Navigation Timing API not available',
fallback: this.collectLegacyTiming()
};
return;
}
this.metrics.pageLoad = {
// Redirect
redirectCount: nt.redirectCount,
redirectTime: nt.redirectEnd - nt.redirectStart,
// DNS
dnsLookupTime: nt.domainLookupEnd - nt.domainLookupStart,
// TCP Connection
tcpConnectionTime: nt.connectEnd - nt.connectStart,
// TLS
tlsTime: nt.secureConnectionStart ?
nt.connectEnd - nt.secureConnectionStart : 0,
// TTFB (Time to First Byte)
ttfb: nt.responseStart - nt.requestStart,
// Download
downloadTime: nt.responseEnd - nt.responseStart,
// DOM processing
domInteractive: nt.domInteractive - nt.domContentLoadedEventStart,
domContentLoadedEvent: nt.domContentLoadedEventEnd - nt.domContentLoadedEventStart,
domCompleteTime: nt.domComplete - nt.domInteractive,
// Load event
loadEventTime: nt.loadEventEnd - nt.loadEventStart,
loadEventEnd: nt.loadEventEnd,
// Total
totalPageLoadTime: nt.loadEventEnd - nt.startTime,
navigationType: nt.type, // 'navigate' | 'reload' | 'back_forward' | 'prerender'
transferSize: nt.transferSize,
encodedBodySize: nt.encodedBodySize,
decodedBodySize: nt.decodedBodySize
};
}
// 兼容旧版 PerformanceTiming API
collectLegacyTiming() {
if (!performance.timing) return null;
const t = performance.timing;
return {
ttfb: t.responseStart - t.requestStart,
domContentLoaded: t.domContentLoadedEventEnd - t.navigationStart,
pageLoad: t.loadEventEnd - t.navigationStart,
note: 'deprecated PerformanceTiming API'
};
}
// 观察 Web Vitals 指标
observeWebVitals() {
this.observeLCP();
this.observeFID();
this.observeCLS();
this.observePaint();
}
// LCP 采集
observeLCP() {
try {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.webVitals.lcp = {
value: lastEntry.startTime,
element: lastEntry.element ? lastEntry.element.localName : null,
size: lastEntry.size,
url: lastEntry.url || null,
loadTime: lastEntry.loadTime || null,
renderTime: lastEntry.renderTime || null
};
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
this.observers.push(observer);
} catch (e) {
console.warn('[RUM] LCP 不可用:', e.message);
}
}
// FID 采集(RUM 中应该使用改进版的 INP)
observeFID() {
try {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
// 只记录首次输入
if (!this.metrics.webVitals.fid) {
this.metrics.webVitals.fid = {
value: entry.processingStart - entry.startTime,
type: entry.name, // 'pointerdown' | 'mousedown' | 'keydown'
target: entry.target ? entry.target.localName : null,
timestamp: entry.startTime
};
}
});
});
observer.observe({ type: 'first-input', buffered: true });
this.observers.push(observer);
} catch (e) {
console.warn('[RUM] FID 不可用:', e.message);
}
}
// CLS 采集
observeCLS() {
try {
let clsValue = 0;
let clsEntries = [];
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
// 排除用户交互后 500ms 内的布局偏移
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push({
value: entry.value,
sources: entry.sources.map(s => ({
node: s.node?.localName || null,
currentRect: s.currentRect,
previousRect: s.previousRect
})),
timestamp: entry.startTime
});
}
});
this.metrics.webVitals.cls = {
value: clsValue,
entryCount: clsEntries.length
};
});
observer.observe({ type: 'layout-shift', buffered: true });
this.observers.push(observer);
} catch (e) {
console.warn('[RUM] CLS 不可用:', e.message);
}
}
// FP / FCP
observePaint() {
try {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
if (entry.name === 'first-paint') {
this.metrics.webVitals.fp = entry.startTime;
} else if (entry.name === 'first-contentful-paint') {
this.metrics.webVitals.fcp = entry.startTime;
}
});
});
observer.observe({ type: 'paint', buffered: true });
this.observers.push(observer);
} catch (e) {
console.warn('[RUM] Paint 不可用:', e.message);
}
}
// 生成会话 ID
generateSessionId() {
return 'rum-' + Math.random().toString(36).substring(2, 10) +
Date.now().toString(36);
}
generatePageId() {
return btoa(window.location.pathname).replace(/=/g, '');
}
// 上报数据
async report() {
// 等待所有 observer 回调完成
await new Promise(resolve => setTimeout(resolve, 500));
// 构建最终数据包
const data = {
...this.metrics,
sessionId: this.sessionId,
timestamp: Date.now(),
visibilityState: document.visibilityState
};
console.log('[RUM] 上报数据:', data);
// 使用 sendBeacon 优先(即使用户已关闭页面也会发送)
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json'
});
if (navigator.sendBeacon) {
navigator.sendBeacon(this.options.endpoint, blob);
} else {
// 回退:使用 fetch + keepalive
try {
await fetch(this.options.endpoint, {
method: 'POST',
body: blob,
keepalive: true,
credentials: 'include'
});
} catch (e) {
console.warn('[RUM] 上报失败:', e);
}
}
}
// 清理 observer
disconnect() {
this.observers.forEach(observer => observer.disconnect());
this.observers = [];
}
}
// 暴露全局
window.RUMCollector = RUMCollector;
// 自动启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
new RUMCollector({
endpoint: '/api/rum',
sampleRate: 0.1, // 10% 采样
autostart: true
});
});
} else {
new RUMCollector({
endpoint: '/api/rum',
sampleRate: 0.1,
autostart: true
});
}
})();
3.2 服务端接收端
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
// rum-server.js
const express = require('express');
const app = express();
app.use(express.json({ limit: '64kb' }));
// RUM 数据接收端点
app.post('/api/rum', (req, res) => {
const rumData = req.body;
// 验证数据完整性
if (!rumData || !rumData.pageInfo || !rumData.sessionId) {
return res.status(400).json({ error: 'Invalid RUM data' });
}
// 结构化存储
const record = {
url: rumData.pageInfo.url,
pageId: rumData.pageInfo.pageId,
sessionId: rumData.sessionId,
timestamp: new Date(rumData.timestamp),
metrics: {
// Web Vitals
lcp: rumData.webVitals?.lcp?.value,
fid: rumData.webVitals?.fid?.value,
cls: rumData.webVitals?.cls?.value,
fcp: rumData.webVitals?.fcp,
fp: rumData.webVitals?.fp,
// Navigation Timing
ttfb: rumData.pageLoad?.ttfb,
domContentLoaded: rumData.pageLoad?.domContentLoadedEvent,
loadEventEnd: rumData.pageLoad?.loadEventEnd,
// 资源占用
transferSize: rumData.pageLoad?.transferSize,
decodedBodySize: rumData.pageLoad?.decodedBodySize,
},
environment: rumData.environment
};
// 写入数据库(示例使用内存存储)
metricsDB.push(record);
// 实时指标计算
const metrics = computeRealtimeMetrics();
console.log('[RUM] 当前 Web Vitals 分布:', metrics);
res.status(200).json({ success: true });
});
// 实时指标计算
const metricsDB = [];
function computeRealtimeMetrics() {
if (metricsDB.length === 0) return {};
const getPercentile = (values, p) => {
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.ceil(sorted.length * p / 100) - 1;
return sorted[Math.max(0, idx)];
};
const lcpValues = metricsDB
.filter(r => r.metrics.lcp != null)
.map(r => r.metrics.lcp);
const fidValues = metricsDB
.filter(r => r.metrics.fid != null)
.map(r => r.metrics.fid);
const clsValues = metricsDB
.filter(r => r.metrics.cls != null)
.map(r => r.metrics.cls);
return {
totalSamples: metricsDB.length,
lcp: {
p50: getPercentile(lcpValues, 50),
p75: getPercentile(lcpValues, 75),
p90: getPercentile(lcpValues, 90),
p99: getPercentile(lcpValues, 99),
good: lcpValues.filter(v => v <= 2500).length / lcpValues.length * 100
},
fid: {
p50: getPercentile(fidValues, 50),
p75: getPercentile(fidValues, 75),
p90: getPercentile(fidValues, 90),
good: fidValues.filter(v => v <= 100).length / fidValues.length * 100
},
cls: {
p50: getPercentile(clsValues, 50),
p75: getPercentile(clsValues, 75),
p90: getPercentile(clsValues, 90),
good: clsValues.filter(v => v <= 0.1).length / clsValues.length * 100
}
};
}
// 获取聚合指标
app.get('/api/rum/metrics', (req, res) => {
res.json(computeRealtimeMetrics());
});
app.listen(8080, () => {
console.log('RUM Server running on http://localhost:8080');
});
3.3 使用标记
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 在 HTML 中使用 -->
<script>
// 1. 直接嵌入 collector
// 包含上面的 RUMCollector 脚本
// 2. 或通过 CDN 加载
(function() {
var script = document.createElement('script');
script.src = 'https://cdn.example.com/rum-collector.min.js';
script.async = true;
script.setAttribute('data-endpoint', '/api/rum');
script.setAttribute('data-sample-rate', '0.1');
document.head.appendChild(script);
})();
</script>
4. 核心知识点拆解
4.1 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
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
class PerformanceObserverManager {
constructor() {
this.observers = new Map();
}
// 注册一个 observer
observe(type, callback, options = {}) {
const key = `${type}-${Math.random().toString(36).substring(2, 8)}`;
const observer = new PerformanceObserver((list) => {
// 自动批量处理
const entries = list.getEntries();
callback(entries, list);
});
observer.observe({
type,
buffered: options.buffered ?? true,
durationThreshold: options.durationThreshold, // 用于 'longtask' 类型
entryTypes: options.entryTypes // 旧版 API
});
this.observers.set(key, observer);
return key;
}
// 取消注册
disconnect(key) {
if (this.observers.has(key)) {
this.observers.get(key).disconnect();
this.observers.delete(key);
}
}
// 取消所有
disconnectAll() {
this.observers.forEach(observer => observer.disconnect());
this.observers.clear();
}
// 便捷方法:一次性注册所有 Web Vitals
observeAllWebVitals() {
const handlers = {
'largest-contentful-paint': (entries) => {
const last = entries[entries.length - 1];
console.log('LCP:', last.startTime, last.element);
},
'first-input': (entries) => {
entries.forEach(e => {
console.log('FID:', e.processingStart - e.startTime);
});
},
'layout-shift': (entries) => {
let cls = 0;
entries.forEach(e => {
if (!e.hadRecentInput) cls += e.value;
});
console.log('CLS:', cls);
},
'paint': (entries) => {
entries.forEach(e => {
console.log(`${e.name}:`, e.startTime);
});
},
'longtask': (entries) => {
entries.forEach(e => {
if (e.duration > 200) {
console.warn('长任务:', e.duration, 'ms');
}
});
},
'navigation': (entries) => {
if (entries[0]) console.log('TTFB:', entries[0].responseStart - entries[0].requestStart);
}
};
Object.entries(handlers).forEach(([type, handler]) => {
try {
this.observe(type, handler);
} catch (e) {
// 某些类型在部分浏览器中不支持
}
});
}
}
buffered: true 的重要性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 场景:脚本在文档末尾加载
// 此时 FP、FCP、甚至 LCP 可能已经发生
// ❌ 没有 buffered
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log('收到:', entry.name);
});
});
observer.observe({ type: 'paint' });
// ⚠️ 如果 FP/FCP 已经发生,这里永不会收到回调
// ✅ 使用 buffered
const observer2 = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log('收到:', entry.name);
});
});
observer2.observe({ type: 'paint', buffered: true });
// ✅ 即使之前发生的事件也会被传递到回调中
4.2 Web Vitals 各个指标的准确采集
LCP 的采集陷阱
LCP 有一个重要特性:它可能变化。浏览器会不断找到更大的内容元素并更新 LCP 值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// LCP 更新机制
// 第一次:图片 <img> 完成加载 → LCP = 1200ms
// 第二次:一个更大的文本块渲染完成 → LCP = 1800ms
// 最终:大图完成加载 → LCP = 2500ms
// LCP observer 会收到多次回调,每次携带最新的 LCP 值
const lcpObserver = new PerformanceObserver((list) => {
// list.getEntries() 包含所有 LCP 候选
// 最后一个是当前最大的
const entries = list.getEntries();
const latest = entries[entries.length - 1];
console.log('LCP 更新:', latest.startTime, '元素:', latest.element?.localName);
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
// 什么时候 LCP 是最终值?
// 当页面加载完成后,或者用户有交互(点击/滚动)时
// Google 的 web-vitals 库会在页面隐藏(visibilitychange → hidden)时取最终值
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
// CLS 并非在页面卸载时一次性计算
// 而是持续累加,但不需要 reset 多个 session
// 关键细节:session window
// Google 的 CLS 定义使用了 session window 的概念
// 即 CLS 取的是 5s 窗口内的最大累积偏移值
// 手动实现 session window:
function calculateCLSWithSessions(entries) {
let maxSessionValue = 0;
let currentSessionValue = 0;
let sessionStart = 0;
const SESSION_GAP = 1000; // 1s 间隔
const SESSION_WINDOW = 5000; // 5s 窗口
entries.forEach(entry => {
if (entry.hadRecentInput) return; // 排除用户交互
// 如果距离上次偏移超过 1s,开始新 session
if (entry.startTime - sessionStart > SESSION_GAP) {
// 保留最大 session
maxSessionValue = Math.max(maxSessionValue, currentSessionValue);
currentSessionValue = 0;
sessionStart = entry.startTime;
}
currentSessionValue += entry.value;
});
return Math.max(maxSessionValue, currentSessionValue);
}
FID vs INP(Interaction to Next Paint)
Google 在 2024 年将 INP 正式取代 FID 作为 Core 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
// FID 测量首次输入延迟(只能测一次)
const fidObserver = new PerformanceObserver((list) => {
const fidEntry = list.getEntries()[0];
console.log('FID:', fidEntry.processingStart - fidEntry.startTime);
});
fidObserver.observe({ type: 'first-input', buffered: true });
// INP 测量所有交互延迟(取最差或 p75)
// 需要手动追踪所有交互事件
const inpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
const delay = entry.processingStart - entry.startTime;
const processingTime = entry.duration;
const totalTime = delay + processingTime;
console.log(`交互: ${entry.name}, 延迟: ${delay}ms, 处理: ${processingTime}ms`);
});
});
// 注意:'event' 类型需要 Chrome 96+
try {
inpObserver.observe({ type: 'event', buffered: true, durationThreshold: 0 });
} catch (e) {
console.warn('INP 不可用,回退到 FID');
}
4.3 性能数据分析的最佳实践
百分位数(Percentile)优于平均数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ❌ 平均数的问题
const latencies = [100, 100, 100, 100, 100];
// 平均数: 100ms ✅ 看起来很好
const latencies2 = [20, 20, 20, 20, 8000];
// 平均数: 1616ms ❌ 看起来很差
// 但 80% 的用户体验很好(20ms)
// ✅ 用百分位数
function percentiles(values) {
const sorted = [...values].sort((a, b) => a - b);
const p50 = sorted[Math.floor(sorted.length * 0.5)];
const p75 = sorted[Math.floor(sorted.length * 0.75)];
const p90 = sorted[Math.floor(sorted.length * 0.9)];
const p95 = sorted[Math.floor(sorted.length * 0.95)];
const p99 = sorted[Math.floor(sorted.length * 0.99)];
return { p50, p75, p90, p95, p99 };
}
console.log(percentiles(latencies2));
// { p50: 20, p75: 20, p90: 8000, p95: 8000, p99: 8000 }
// ✅ 更真实地反映了 "大部分用户快,小部分极慢" 的情况
分组聚合
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
// 按设备类型分组对比
function groupMetricsByDevice(records) {
const groups = {
desktop: [],
mobile: [],
tablet: []
};
records.forEach(r => {
const ua = r.environment.userAgent;
if (/Tablet|iPad/.test(ua)) {
groups.tablet.push(r);
} else if (/Mobi|Android/.test(ua)) {
groups.mobile.push(r);
} else {
groups.desktop.push(r);
}
});
const result = {};
Object.entries(groups).forEach(([device, groupRecords]) => {
if (groupRecords.length === 0) return;
const lcpValues = groupRecords.map(r => r.metrics.lcp).filter(Boolean);
result[device] = {
count: groupRecords.length,
lcpP75: getPercentile(lcpValues, 75),
lcpP90: getPercentile(lcpValues, 90),
};
});
return result;
}
5. 实战案例
案例一:构建企业级 RUM 监控 SDK
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
// rum-sdk.ts
// 企业级 RUM SDK 的核心设计
interface RUMConfig {
appId: string; // 应用 ID
endpoint: string; // 上报端点
sampleRate: number; // 采样率 0-1
disabled?: boolean; // 禁用
maxBatchSize?: number; // 批量上报最大条目
flushInterval?: number; // 批量上报间隔(ms)
customDimensions?: Record<string, string>;
}
interface PerformanceRecord {
appId: string;
sessionId: string;
pageId: string;
timestamp: number;
type: 'web_vital' | 'navigation' | 'resource' | 'error' | 'custom';
name: string;
value: number;
metadata?: Record<string, any>;
}
class RUMSDK {
private config: Required<RUMConfig>;
private buffer: PerformanceRecord[] = [];
private sessionId: string;
private pageId: string;
private startTime: number;
private timers: Map<string, number> = new Map();
constructor(config: RUMConfig) {
if (config.disabled) {
console.log('[RUM SDK] 已禁用');
return;
}
this.config = {
appId: config.appId,
endpoint: config.endpoint,
sampleRate: config.sampleRate,
maxBatchSize: 10,
flushInterval: 5000,
customDimensions: {},
...config
};
this.sessionId = this.generateId('session');
this.pageId = this.generateId('page');
this.startTime = performance.now();
if (Math.random() > this.config.sampleRate) {
console.log('[RUM SDK] 未命中采样率');
return;
}
this.init();
}
private init() {
// 注册 Web Vitals 监听
this.registerWebVitalObservers();
// 注册资源加载监听
if (PerformanceObserver.supportedEntryTypes?.includes('resource')) {
this.registerResourceObserver();
}
// 批量上报定时器
setInterval(() => this.flush(), this.config.flushInterval);
// 页面隐藏时上报
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
}
private registerWebVitalObservers() {
const vitals: {
type: string;
handler: (entry: PerformanceEntry) => number | null;
}[] = [
{
type: 'largest-contentful-paint',
handler: (entry: any) => entry.startTime
},
{
type: 'first-input',
handler: (entry: any) => entry.processingStart - entry.startTime
},
{
type: 'layout-shift',
handler: (entry: any) => !entry.hadRecentInput ? entry.value : null
},
{
type: 'paint',
handler: (entry: any) => entry.name === 'first-contentful-paint' ? entry.startTime : null
}
];
vitals.forEach(({ type, handler }) => {
try {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
const value = handler(entry);
if (value !== null) {
this.record({
type: 'web_vital',
name: type === 'largest-contentful-paint' ? 'LCP'
: type === 'first-input' ? 'FID'
: type === 'layout-shift' ? 'CLS'
: type === 'paint' && entry.name === 'first-contentful-paint' ? 'FCP'
: type,
value,
metadata: { entryName: entry.name }
});
}
});
});
observer.observe({ type, buffered: true });
} catch (e) {
// 不支持的类型忽略
}
});
}
private registerResourceObserver() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry: any) => {
// 只关注关键资源
if (entry.initiatorType === 'script' ||
entry.initiatorType === 'link' ||
entry.entryType === 'navigation') {
this.record({
type: 'resource',
name: entry.name,
value: entry.duration,
metadata: {
initiatorType: entry.initiatorType,
transferSize: entry.transferSize,
encodedBodySize: entry.encodedBodySize,
decodedBodySize: entry.decodedBodySize,
dnsTime: entry.domainLookupEnd - entry.domainLookupStart,
tcpTime: entry.connectEnd - entry.connectStart,
ttfb: entry.responseStart - entry.requestStart,
}
});
}
});
});
observer.observe({ type: 'resource', buffered: true });
}
// 公开接口:记录自定义时间
trackTiming(name: string, value?: number) {
if (!value) {
this.timers.set(name, performance.now());
return;
}
this.record({
type: 'custom',
name,
value
});
}
// 公开接口:停止计时器
stopTiming(name: string) {
const start = this.timers.get(name);
if (start) {
const duration = performance.now() - start;
this.record({
type: 'custom',
name,
value: duration
});
this.timers.delete(name);
}
}
// 内部记录器
private record(data: Partial<PerformanceRecord>) {
const record: PerformanceRecord = {
appId: this.config.appId,
sessionId: this.sessionId,
pageId: this.pageId,
timestamp: Date.now(),
type: data.type!,
name: data.name!,
value: data.value!,
metadata: {
...this.config.customDimensions,
...data.metadata,
url: window.location.href,
referrer: document.referrer,
screenWidth: window.innerWidth,
connection: (navigator as any).connection?.effectiveType,
}
};
this.buffer.push(record);
if (this.buffer.length >= this.config.maxBatchSize) {
this.flush();
}
}
// 批量上报
private async flush() {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0, this.config.maxBatchSize);
const payload = JSON.stringify(batch);
// 使用 sendBeacon 或 fetch keepalive
try {
if (navigator.sendBeacon && payload.length < 65536) {
navigator.sendBeacon(this.config.endpoint, new Blob([payload], { type: 'application/json' }));
} else {
await fetch(this.config.endpoint, {
method: 'POST',
body: payload,
keepalive: true,
headers: { 'Content-Type': 'application/json' }
});
}
} catch (e) {
console.warn('[RUM SDK] 上报失败', e);
}
}
private generateId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}`;
}
}
// 使用示例
const rum = new RUMSDK({
appId: 'my-app',
endpoint: 'https://rum.example.com/api/v1/events',
sampleRate: 0.5,
customDimensions: {
version: '1.2.3',
environment: 'production'
}
});
// 自定义性能追踪
rum.trackTiming('search-submit');
// ... 搜索逻辑 ...
rum.stopTiming('search-submit');
6. 底层原理
6.1 Performance API 的浏览器实现
Performance API 的实现位于 Blink 引擎的 third_party/blink/renderer/core/timing/ 目录下。
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
// Chromium 源码简化版: PerformanceObserver 的调度机制
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/timing/performance_observer.cc
// PerformanceObserver 的缓冲机制
void PerformanceObserver::EnqueuePerformanceEntry(PerformanceEntry& entry) {
// 1. 先检查 observer 是否匹配这个 entry type
if (!IsInterestedIn(entry->EntryType()))
return;
// 2. 将 entry 加入 observer 的缓冲区
performance_entries_.push_back(&entry);
// 3. 设置一个微任务来触发回调
// 使用微任务(microtask)而不是宏任务(macrotask)保证:
// - 批量处理:多个 entry 在同一帧内一起回调
// - 及时性:在下一个 task 之前
if (!is_scheduled_) {
is_scheduled_ = true;
// 注册微任务回调
Microtask::EnqueueMicrotask(
WTF::Bind(&PerformanceObserver::Deliver, WrapWeakPersistent(this)));
}
}
// 回调递送:批量处理 buffer 中的全部 entry
void PerformanceObserver::Deliver() {
is_scheduled_ = false;
// 交换出 buffer,避免递归调用
PerformanceEntryVector entries;
swap(performance_entries_, entries);
// 批量回调
callback_->Invoke(this, entries);
}
6.2 LCP 的确定逻辑
LCP 的计算远比想象中复杂。在 Blink 源码中,需要持续追踪页面中最大的内容元素:
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
// Chromium 源码简化: LargestContentfulPaint 的更新逻辑
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/paint/largest_contentful_paint_calculator.cc
// 当新内容渲染时,检查是否需要更新 LCP
void LargestContentfulPaintCalculator::OnContentPaint(
const LayoutObject& object,
const gfx::Rect& rect) {
// 1. 计算当前内容元素的可视面积
uint64_t area = rect.width() * rect.height();
// 2. 只考虑包含视口中的内容
if (!rect.Intersects(viewport_bounds_))
return;
// 3. 对于图片,必须加载完毕才算完成
if (object.IsImage()) {
ImageResourceContent* image = /* ... */;
if (!image || !image->IsLoaded())
return; // 图片未加载完成,等待下一次
}
// 4. 和当前最大元素比较
if (area > largest_area_) {
// 更新 LCP
largest_area_ = area;
UpdateLargestContentfulPaintEntry(object, rect);
// 触发 PerformanceObserver 回调
UpdateLCPEntry();
}
}
6.3 CLS 的精确计算
CLS 的计算涉及 layout shift 的检测,这在浏览器内部是一个持续运行的过程:
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
// Chromium 源码简化: LayoutShift 的检测机制
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/layout/layout_shift_tracker.cc
// 每个动画帧完成后检查布局偏移
void LayoutShiftTracker::NotifyBeforeCompositorFrame() {
// 1. 检查是否有元素发生了位置变化
for (const auto& shift : pending_shifts_) {
// 2. 计算偏移的 delta
float distance = shift.CurrentRect() - shift.PreviousRect();
// 3. 计算影响区域
gfx::RectF union_rect = UnionRects(shift.CurrentRect(), shift.PreviousRect());
gfx::RectF viewport = GetViewportRect();
float impacted_ratio = Intersect(union_rect, viewport).Area() / viewport.Area();
float distance_ratio = distance / std::max(viewport.Width(), viewport.Height());
// 4. 计算 CLS 分数
float score = impacted_ratio * distance_ratio;
// 5. 聚合到当前 session 窗口
if (!shift.had_recent_input_) {
AccumulateSessionWindow(score, shift.Timestamp());
}
}
pending_shifts_.clear();
}
6.4 High Resolution Time
performance.now() 返回的是 DOMHighResTimeStamp,其精度远超 Date.now():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Date.now() vs performance.now()
console.log(Date.now()); // 1718123456789 (毫秒级,精度约1ms)
console.log(performance.now()); // 12345.678999999 (微秒级,精度可达5μs)
// performance.now() 的特性:
// 1. 不受系统时间调整影响(单调递增)
// 2. 从页面导航开始计时(不是从 Unix 纪元)
// 3. 精度在 Chrome 中被限制为 5μs 以防止 Spectre 时序攻击
// 示波器级别的计时
const start = performance.now();
// ... 待测代码 ...
const elapsed = performance.now() - start;
console.log(`耗时: ${elapsed.toFixed(3)}ms`);
7. 高频面试题解析
面试题 1:LCP 为什么有可能在页面加载过程中变化?最终值如何确定?
答案:
LCP 变化的原因是浏览器在不断寻找更大”更有意义”的内容元素:
变化场景:
- 页面首先渲染了一个文本标题(面积 300×50 = 15000px²)→ LCP = 800ms
- 一个中等大小的图片加载完成(面积 400×300 = 120000px²)→ LCP = 1500ms
- 一个更大的首图终于完成加载(面积 1920×600 = 1152000px²)→ LCP = 2500ms
- LCP 最终值 = 2500ms
最终值的确定时机: LCP 的最终值在以下任一条件满足时确定:
- 页面加载完成(
onload事件触发) - 用户与页面交互(点击、滚动、键盘输入)
- 页面切换到后台(
visibilitychange→hidden) - 超过 5 秒没有新的 LCP 候选(Chrome 内部机制)
重要陷阱:
1
2
3
4
5
// 有些开发者会在 onload 中获取 LCP,但这是错误的
window.addEventListener('load', () => {
// ❌ 此时 LCP 可能还不是最终值
// 因为 onload 后可能还有延迟加载的大图
});
正确做法:
1
2
3
4
5
6
7
8
9
10
11
// ✅ 方法一:监听 visibilitychange hidden
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 此时 LCP 是最终值 ✅
}
});
// ✅ 方法二:监听用户交互
document.addEventListener('click', () => {
// 用户交互后 LCP 不再更新 ✅
}, { once: true });
面试题 2:PerformanceObserver 的 buffered: true 是如何工作的?如果页面加载完毕后注册 observer,buffered: true 还能获取已经发生的事件吗?
答案:
可以。这正是 buffered: true 的设计目的。
工作原理: 浏览器内部维护一个 PerformanceEntryBuffer(性能条目缓冲区),当新的 PerformanceObserver 注册时并设置 buffered: true,浏览器会:
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
// 伪代码展示 buffered 的内部机制
class PerformanceEntryBuffer {
constructor(maxSize = 150) {
this.entries = [];
this.maxSize = maxSize; // Chrome 限制 150 条
}
add(entry) {
this.entries.push(entry);
// 缓冲区满时,移除最旧的条目
if (this.entries.length > this.maxSize) {
this.entries.shift();
}
}
// 当 observer 设置 buffered: true 时调用
replayToObserver(observer) {
// 将缓冲区中的历史条目发送给新注册的 observer
for (const entry of this.entries) {
observer.callback([entry]);
}
}
}
// 使用
const buffer = new PerformanceEntryBuffer();
// 时刻 100ms: FP 事件发生 → 存入 buffer
buffer.add({ name: 'first-paint', startTime: 100 });
// 时刻 200ms: FCP 事件发生 → 存入 buffer
buffer.add({ name: 'first-contentful-paint', startTime: 200 });
// 时刻 3000ms: 开发者注册 observer(此时 FP/FCP 已经发生)
const observer = new PerformanceObserver(callback);
observer.observe({ type: 'paint', buffered: true });
// → buffer.replayToObserver(observer)
// → callback 被调用,传入 [{name:'first-paint', startTime:100}, {name:'first-contentful-paint', startTime:200}]
限制:
- 缓冲区大小限制:Chrome 每个类型的 buffer 最多 150 条,超出后被覆盖
- 仅限支持的类型:不是所有 PerformanceEntry 都支持 buffered
- 仅限注册时快照:replay 只发一次,后续的新事件通过正常 observer 回调机制
面试题 3:CLS 中 hadRecentInput 是什么意思?为什么布局偏移要排除用户交互后的 500ms?
答案:
hadRecentInput 标记该布局偏移是否发生在用户交互后 500ms 以内。
为什么排除用户交互后的偏移?
因为用户发起意图的布局变化应该被排除在 CLS 之外。举例:
1
2
3
4
5
6
7
8
9
10
// 场景一:用户点击"展开更多"按钮(✅ 用户意图,应排除)
button.addEventListener('click', () => {
// 下方展开新的内容,导致布局偏移
content.classList.toggle('expanded');
// 这个偏移由用户交互触发,CLS 应该忽略
});
// 场景二:广告延迟加载导致页面突然下移(❌ 非用户意图,应计入)
// 用户正在阅读文章,一个广告位突然出现,内容全部下移 300px
// 用户没有交互,这是最典型的 CLS 问题
500ms 窗口的技术原因:
- 100ms 是人类的正常反应时间(Google UX 研究)
- 500ms 是一个安全裕度,覆盖了大部分用户交互 + 浏览器事件响应的综合延迟
- 少于 500ms 会有误报(用户点了按钮,但偏移在 400ms 时发生,确实是用户意图导致的)
- 大于 500ms 则覆盖过多,可能漏掉 CLS 问题
1
2
3
4
5
6
7
8
9
10
11
// 浏览器内部时间线
用户点击按钮 → 点击事件处理(同步) → 执行 click handler → DOM 变化 → 渲染 → 布局偏移
0ms 5-20ms 30-50ms 50-100ms 100-200ms 200-400ms
←────────────── 用户交互导致的变化 ──────────────→
←─ 500ms 窗口 ─→ ✅ 偏移被认为是"用户意图",排除在 CLS 之外
// 如果偏移发生在 600ms 后:
用户点击 → ... → 布局稳定 → ... → 广告突然插入 → 布局偏移
0ms 200ms 700ms ❌ 离用户交互已超过 500ms
←─ 500ms 窗口 ─→ ← 偏移发生在此,CLS 计入
8. 总结与扩展
核心要点回顾
- RUM vs Synthetic:生产环境必须用 RUM 获取真实用户数据,Synthetic 用于 CI 回归测试和深度诊断
- Web Vitals 三大指标:LCP(加载性能)、FID/INP(交互性能)、CLS(视觉稳定性)
- PerformanceObserver:推荐使用 observer 模式替代旧的
getEntries(),利用buffered: true获取历史事件 - 数据上报:使用
sendBeacon或fetch keepalive在页面不可见时上报,保证数据完整 - 分析策略:使用百分位数(P75/P90/P99)而非平均数,按设备/网络/地域分组对比
值得继续深挖的方向
- INP(Interaction to Next Paint):Google 的新 CWV 指标,替代 FID,测量所有交互(含点击、键盘、拖拽)的延迟
- Long Animation Frames (LoAF):Chrome 119+ 的实验性 API,比 longtask 更精确的帧率分析
- Navigation API:Performance API 的新版本规范
- Web Worker 中的 Performance:Worker 线程中的性能数据采集
- Server Timing:后端性能数据通过 HTTP 头透传至前端
思考题
- 如果一个 SPA 应用中使用了懒加载路由,
PerformanceNavigationTiming提供的domContentLoadedEventEnd是否还有参考价值?SPA 的”加载完成”应该如何定义? - 当网络质量很差(
effectiveType: 'slow-2g')时,用户对 LCP 的容忍度远高于基线阈值。如何在监控系统中设计自适应的性能目标? PerformanceObserver的durationThreshold参数对longtask类型的含义是什么?如果设为 0 会发生什么?
参考资源: