performance-helper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,377 @@
1
+ import { getElementPath } from '../utils/index';
2
+ /**
3
+ * 性能指标采集器
4
+ */
5
+ export class PerformanceCollector {
6
+ constructor() {
7
+ this.metrics = {};
8
+ this.initialized = false;
9
+ }
10
+ /**
11
+ * 初始化观察器(需要在页面加载前调用)
12
+ */
13
+ init() {
14
+ if (this.initialized) {
15
+ return;
16
+ }
17
+ // 初始化所有性能观察器
18
+ this.collectWebVitals();
19
+ this.initialized = true;
20
+ }
21
+ /**
22
+ * 采集所有性能指标
23
+ */
24
+ collect() {
25
+ this.collectNavigationTiming();
26
+ return { ...this.metrics };
27
+ }
28
+ /**
29
+ * 采集 Navigation Timing 指标
30
+ * 使用 PerformanceNavigationTiming API(新标准)
31
+ */
32
+ collectNavigationTiming() {
33
+ if (!window.performance || !window.performance.getEntriesByType) {
34
+ return;
35
+ }
36
+ try {
37
+ const navigationEntries = window.performance.getEntriesByType('navigation');
38
+ const navigation = navigationEntries[0];
39
+ if (!navigation) {
40
+ return;
41
+ }
42
+ // DNS 查询时间
43
+ this.metrics.dns = Math.round(navigation.domainLookupEnd - navigation.domainLookupStart);
44
+ // TCP 连接时间
45
+ this.metrics.tcp = Math.round(navigation.connectEnd - navigation.connectStart);
46
+ // TTFB (Time to First Byte) - 从请求发送到收到第一个字节
47
+ // 标准定义:responseStart - requestStart
48
+ // 如果 requestStart 不存在(如缓存),则使用 fetchStart 作为备选
49
+ this.metrics.ttfb = Math.round(navigation.responseStart - (navigation.requestStart || navigation.fetchStart));
50
+ // 请求响应时间
51
+ this.metrics.request = Math.round(navigation.responseEnd - navigation.responseStart);
52
+ // DOM 解析时间
53
+ this.metrics.parse = Math.round(navigation.domInteractive - navigation.responseEnd);
54
+ // DOMContentLoaded 时间
55
+ // 使用 startTime(对应旧 API 的 navigationStart),表示导航开始的时间
56
+ // 这比 fetchStart 更准确,因为 startTime 是 PerformanceEntry 的标准属性
57
+ this.metrics.domContentLoaded = Math.round(navigation.domContentLoadedEventEnd - navigation.startTime);
58
+ // Load 完成时间
59
+ // 同样使用 startTime 作为基准,保持一致性
60
+ this.metrics.load = Math.round(navigation.loadEventEnd - navigation.startTime);
61
+ }
62
+ catch (e) {
63
+ // 浏览器不支持新 API,降级到旧 API
64
+ this.collectNavigationTimingLegacy();
65
+ }
66
+ }
67
+ /**
68
+ * 降级方案:使用旧的 PerformanceTiming API(已废弃,仅作兼容)
69
+ */
70
+ collectNavigationTimingLegacy() {
71
+ if (!window.performance || !window.performance.timing) {
72
+ return;
73
+ }
74
+ const timing = window.performance.timing;
75
+ // DNS 查询时间
76
+ this.metrics.dns = timing.domainLookupEnd - timing.domainLookupStart;
77
+ // TCP 连接时间
78
+ this.metrics.tcp = timing.connectEnd - timing.connectStart;
79
+ // TTFB (Time to First Byte)
80
+ this.metrics.ttfb = timing.responseStart - timing.navigationStart;
81
+ // 请求响应时间
82
+ this.metrics.request = timing.responseEnd - timing.responseStart;
83
+ // DOM 解析时间
84
+ this.metrics.parse = timing.domInteractive - timing.responseEnd;
85
+ // DOMContentLoaded 时间
86
+ this.metrics.domContentLoaded = timing.domContentLoadedEventEnd - timing.navigationStart;
87
+ // Load 完成时间
88
+ this.metrics.load = timing.loadEventEnd - timing.navigationStart;
89
+ }
90
+ /**
91
+ * 观察 FCP (First Contentful Paint)
92
+ * 使用 PerformanceObserver 更可靠
93
+ */
94
+ observeFCP() {
95
+ // 先尝试从已有的 paint entries 中读取(适用于页面已加载完成的情况)
96
+ if (window.performance && window.performance.getEntriesByType) {
97
+ const paintEntries = window.performance.getEntriesByType('paint');
98
+ paintEntries.forEach((entry) => {
99
+ if (entry.name === 'first-contentful-paint') {
100
+ this.metrics.fcp = Math.round(entry.startTime);
101
+ }
102
+ });
103
+ }
104
+ // 如果还没有 FCP 值,使用 PerformanceObserver 观察(适用于页面加载中的情况)
105
+ if (this.metrics.fcp === undefined && 'PerformanceObserver' in window) {
106
+ try {
107
+ this.fcpObserver = new PerformanceObserver((list) => {
108
+ const entries = list.getEntries();
109
+ entries.forEach((entry) => {
110
+ if (entry.name === 'first-contentful-paint') {
111
+ this.metrics.fcp = Math.round(entry.startTime);
112
+ // 获取到值后可以断开观察
113
+ if (this.fcpObserver) {
114
+ this.fcpObserver.disconnect();
115
+ }
116
+ }
117
+ });
118
+ });
119
+ this.fcpObserver.observe({ type: 'paint', buffered: true });
120
+ }
121
+ catch (e) {
122
+ // 浏览器不支持
123
+ }
124
+ }
125
+ }
126
+ /**
127
+ * 初始化所有性能观察器
128
+ * 在 init() 时调用,用于设置 PerformanceObserver
129
+ */
130
+ collectWebVitals() {
131
+ this.observeFCP();
132
+ this.observeLCP();
133
+ this.observeFID();
134
+ this.observeCLS();
135
+ this.observeTBT();
136
+ }
137
+ /**
138
+ * 观察 LCP
139
+ */
140
+ observeLCP() {
141
+ if (!('PerformanceObserver' in window)) {
142
+ return;
143
+ }
144
+ try {
145
+ this.lcpObserver = new PerformanceObserver((list) => {
146
+ var _a, _b;
147
+ const entries = list.getEntries();
148
+ const lastEntry = entries[entries.length - 1];
149
+ // LCP 值计算优先级:
150
+ // 1. renderTime - 元素实际渲染到屏幕的时间(最准确,适用于图片等资源)
151
+ // 2. startTime - PerformanceEntry 标准属性(总是存在,适用于文本节点等)
152
+ // 3. loadTime - 资源加载完成时间(作为最后备选)
153
+ const lcpValue = (_b = (_a = lastEntry.renderTime) !== null && _a !== void 0 ? _a : lastEntry.startTime) !== null && _b !== void 0 ? _b : lastEntry.loadTime;
154
+ if (lcpValue) {
155
+ this.metrics.lcp = Math.round(lcpValue);
156
+ // 记录LCP元素的路径
157
+ // LargestContentfulPaint entry 的 element 属性指向导致 LCP 的 DOM 元素
158
+ if (lastEntry.element && lastEntry.element instanceof Element) {
159
+ const elementPath = getElementPath(lastEntry.element);
160
+ if (elementPath) {
161
+ this.metrics.lcpElement = elementPath;
162
+ }
163
+ }
164
+ }
165
+ });
166
+ this.lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
167
+ }
168
+ catch (e) {
169
+ // 浏览器不支持或已触发
170
+ }
171
+ }
172
+ /**
173
+ * 观察 FID
174
+ */
175
+ observeFID() {
176
+ if (!('PerformanceObserver' in window)) {
177
+ return;
178
+ }
179
+ try {
180
+ this.fidObserver = new PerformanceObserver((list) => {
181
+ const entries = list.getEntries();
182
+ entries.forEach((entry) => {
183
+ const eventEntry = entry;
184
+ if (eventEntry.processingStart && eventEntry.startTime) {
185
+ this.metrics.fid = Math.round(eventEntry.processingStart - eventEntry.startTime);
186
+ // FID 只需要第一个输入,获取后可以断开观察
187
+ if (this.fidObserver) {
188
+ this.fidObserver.disconnect();
189
+ }
190
+ }
191
+ });
192
+ });
193
+ this.fidObserver.observe({ type: 'first-input', buffered: true });
194
+ }
195
+ catch (e) {
196
+ // 浏览器不支持
197
+ }
198
+ }
199
+ /**
200
+ * 观察 CLS
201
+ */
202
+ observeCLS() {
203
+ if (!('PerformanceObserver' in window)) {
204
+ return;
205
+ }
206
+ let clsValue = 0;
207
+ let clsEntries = [];
208
+ try {
209
+ this.clsObserver = new PerformanceObserver((list) => {
210
+ for (const entry of list.getEntries()) {
211
+ // layout-shift 类型返回的是 LayoutShift
212
+ const layoutShift = entry;
213
+ // 只统计没有 recent user input 的布局偏移
214
+ if (!layoutShift.hadRecentInput) {
215
+ const firstSessionEntry = clsEntries[0];
216
+ const lastSessionEntry = clsEntries[clsEntries.length - 1];
217
+ // 如果 entry 与上一个 entry 间隔小于 1 秒,且与第一个 entry 间隔小于 5 秒,则合并到当前会话
218
+ if (clsValue &&
219
+ clsEntries.length > 0 &&
220
+ layoutShift.startTime - lastSessionEntry.startTime < 1000 &&
221
+ layoutShift.startTime - firstSessionEntry.startTime < 5000) {
222
+ clsValue += layoutShift.value;
223
+ clsEntries.push(layoutShift);
224
+ }
225
+ else {
226
+ clsValue = layoutShift.value;
227
+ clsEntries = [layoutShift];
228
+ }
229
+ }
230
+ }
231
+ this.metrics.cls = Math.round(clsValue * 1000) / 1000;
232
+ });
233
+ this.clsObserver.observe({ type: 'layout-shift', buffered: true });
234
+ }
235
+ catch (e) {
236
+ // 浏览器不支持
237
+ }
238
+ }
239
+ /**
240
+ * 观察长任务并计算 TBT (Total Blocking Time)
241
+ * TBT = 所有长任务(>50ms)的阻塞时间总和
242
+ * 阻塞时间 = 任务持续时间 - 50ms
243
+ */
244
+ observeTBT() {
245
+ if (!('PerformanceObserver' in window)) {
246
+ return;
247
+ }
248
+ try {
249
+ // 初始化长任务数组和 TBT
250
+ if (!this.metrics.longTasks) {
251
+ this.metrics.longTasks = [];
252
+ }
253
+ this.metrics.tbt = 0;
254
+ this.longTaskObserver = new PerformanceObserver((list) => {
255
+ for (const entry of list.getEntries()) {
256
+ // 长任务定义为超过 50ms 的任务
257
+ if (entry.duration > 50) {
258
+ const longTaskEntry = entry; // PerformanceLongTaskTiming
259
+ // 提取归因信息
260
+ const attribution = [];
261
+ if (longTaskEntry.attribution && Array.isArray(longTaskEntry.attribution)) {
262
+ longTaskEntry.attribution.forEach((attr) => {
263
+ const attributionInfo = {
264
+ taskType: attr.entryType || attr.name || 'unknown',
265
+ name: attr.name,
266
+ };
267
+ // 容器信息(如果是 iframe 等)
268
+ if (attr.containerType) {
269
+ attributionInfo.containerType = attr.containerType;
270
+ }
271
+ if (attr.containerName) {
272
+ attributionInfo.containerName = attr.containerName;
273
+ }
274
+ if (attr.containerId) {
275
+ attributionInfo.containerId = attr.containerId;
276
+ }
277
+ if (attr.containerSrc) {
278
+ attributionInfo.containerSrc = attr.containerSrc;
279
+ }
280
+ // 脚本URL(如果是脚本执行导致的长任务)
281
+ if (attr.scriptURL) {
282
+ attributionInfo.scriptURL = attr.scriptURL;
283
+ }
284
+ // DOM元素信息(如果是DOM操作导致的长任务)
285
+ if (attr.element) {
286
+ const element = attr.element;
287
+ attributionInfo.element = element;
288
+ attributionInfo.elementPath = getElementPath(element);
289
+ attributionInfo.elementTag = element.tagName.toLowerCase();
290
+ if (element.id) {
291
+ attributionInfo.elementId = element.id;
292
+ }
293
+ if (element.className && typeof element.className === 'string') {
294
+ attributionInfo.elementClass = element.className.trim();
295
+ }
296
+ }
297
+ attribution.push(attributionInfo);
298
+ });
299
+ }
300
+ else {
301
+ // 如果没有 attribution,尝试从 entry 本身推断
302
+ // 某些浏览器可能不支持 attribution,但我们可以从其他信息推断
303
+ const fallbackInfo = {
304
+ taskType: 'unknown',
305
+ name: entry.name || 'long-task',
306
+ };
307
+ // 尝试从 entry 中提取脚本信息
308
+ if (entry.scriptURL) {
309
+ fallbackInfo.scriptURL = entry.scriptURL;
310
+ }
311
+ attribution.push(fallbackInfo);
312
+ }
313
+ // 添加到长任务列表
314
+ const longTaskInfo = {
315
+ startTime: Math.round(entry.startTime),
316
+ duration: Math.round(entry.duration),
317
+ attribution,
318
+ };
319
+ this.metrics.longTasks.push(longTaskInfo);
320
+ // 累加 TBT(阻塞时间 = duration - 50ms)
321
+ const blockingTime = Math.round(entry.duration - 50);
322
+ this.metrics.tbt = (this.metrics.tbt || 0) + blockingTime;
323
+ }
324
+ }
325
+ });
326
+ this.longTaskObserver.observe({ type: 'longtask', buffered: true });
327
+ // 重新计算 TBT(确保准确性,处理所有 buffered entries)
328
+ // 因为 buffered: true 会立即触发回调,但为了确保准确性,重新计算一次
329
+ this.updateTBT();
330
+ }
331
+ catch (e) {
332
+ // 浏览器不支持 longtask API
333
+ }
334
+ }
335
+ /**
336
+ * 更新 TBT (Total Blocking Time)
337
+ * 从已收集的长任务列表中重新计算 TBT,确保准确性
338
+ */
339
+ updateTBT() {
340
+ const longTasks = this.metrics.longTasks;
341
+ if (!longTasks || longTasks.length === 0) {
342
+ this.metrics.tbt = 0;
343
+ return;
344
+ }
345
+ // 重新计算 TBT(确保准确性)
346
+ let tbt = 0;
347
+ longTasks.forEach((task) => {
348
+ // 只有 duration > 50ms 的任务才计入 TBT
349
+ // 阻塞时间 = duration - 50ms
350
+ if (task.duration > 50) {
351
+ tbt += task.duration - 50;
352
+ }
353
+ });
354
+ this.metrics.tbt = Math.round(tbt);
355
+ }
356
+ /**
357
+ * 销毁观察器
358
+ */
359
+ destroy() {
360
+ if (this.lcpObserver) {
361
+ this.lcpObserver.disconnect();
362
+ }
363
+ if (this.fidObserver) {
364
+ this.fidObserver.disconnect();
365
+ }
366
+ if (this.clsObserver) {
367
+ this.clsObserver.disconnect();
368
+ }
369
+ if (this.fcpObserver) {
370
+ this.fcpObserver.disconnect();
371
+ }
372
+ if (this.longTaskObserver) {
373
+ this.longTaskObserver.disconnect();
374
+ }
375
+ this.initialized = false;
376
+ }
377
+ }
@@ -0,0 +1,38 @@
1
+ import { ReportData, PerformanceHelperOptions } from '../types';
2
+ /**
3
+ * 数据上报器
4
+ */
5
+ export declare class Reporter {
6
+ private reportUrl;
7
+ private appId?;
8
+ private userId?;
9
+ private immediate;
10
+ private batchInterval;
11
+ private queue;
12
+ private timer?;
13
+ constructor(options: PerformanceHelperOptions);
14
+ /**
15
+ * 上报数据
16
+ */
17
+ report(data: Omit<ReportData, 'timestamp' | 'url' | 'userAgent'>): void;
18
+ /**
19
+ * 批量上报
20
+ */
21
+ private batchReport;
22
+ /**
23
+ * 发送数据
24
+ */
25
+ private send;
26
+ /**
27
+ * 启动批量定时器
28
+ */
29
+ private startBatchTimer;
30
+ /**
31
+ * 设置页面卸载时的上报
32
+ */
33
+ private setupBeforeUnload;
34
+ /**
35
+ * 销毁上报器
36
+ */
37
+ destroy(): void;
38
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 数据上报器
3
+ */
4
+ export class Reporter {
5
+ constructor(options) {
6
+ this.queue = [];
7
+ this.reportUrl = options.reportUrl;
8
+ this.appId = options.appId;
9
+ this.userId = options.userId;
10
+ this.immediate = options.immediate || false;
11
+ this.batchInterval = options.batchInterval || 5000;
12
+ if (!this.immediate) {
13
+ this.startBatchTimer();
14
+ }
15
+ // 页面卸载时上报剩余数据
16
+ this.setupBeforeUnload();
17
+ }
18
+ /**
19
+ * 上报数据
20
+ */
21
+ report(data) {
22
+ const reportData = {
23
+ ...data,
24
+ timestamp: Date.now(),
25
+ url: window.location.href,
26
+ userAgent: navigator.userAgent,
27
+ appId: this.appId,
28
+ userId: this.userId,
29
+ };
30
+ if (this.immediate) {
31
+ this.send([reportData]);
32
+ }
33
+ else {
34
+ this.queue.push(reportData);
35
+ }
36
+ }
37
+ /**
38
+ * 批量上报
39
+ */
40
+ batchReport() {
41
+ if (this.queue.length === 0) {
42
+ return;
43
+ }
44
+ const dataToSend = [...this.queue];
45
+ this.queue = [];
46
+ this.send(dataToSend);
47
+ }
48
+ /**
49
+ * 发送数据
50
+ */
51
+ send(data) {
52
+ // 使用 sendBeacon 优先,fallback 到 fetch
53
+ if (navigator.sendBeacon) {
54
+ const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
55
+ navigator.sendBeacon(this.reportUrl, blob);
56
+ }
57
+ else {
58
+ // 使用 fetch 发送
59
+ fetch(this.reportUrl, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ },
64
+ body: JSON.stringify(data),
65
+ keepalive: true,
66
+ }).catch((error) => {
67
+ console.error('Report failed:', error);
68
+ // 发送失败,重新加入队列
69
+ if (!this.immediate) {
70
+ this.queue.push(...data);
71
+ }
72
+ });
73
+ }
74
+ }
75
+ /**
76
+ * 启动批量定时器
77
+ */
78
+ startBatchTimer() {
79
+ this.timer = window.setInterval(() => {
80
+ this.batchReport();
81
+ }, this.batchInterval);
82
+ }
83
+ /**
84
+ * 设置页面卸载时的上报
85
+ */
86
+ setupBeforeUnload() {
87
+ window.addEventListener('beforeunload', () => {
88
+ if (this.queue.length > 0) {
89
+ // 使用 sendBeacon 确保数据能发送
90
+ const blob = new Blob([JSON.stringify(this.queue)], { type: 'application/json' });
91
+ navigator.sendBeacon(this.reportUrl, blob);
92
+ }
93
+ });
94
+ }
95
+ /**
96
+ * 销毁上报器
97
+ */
98
+ destroy() {
99
+ if (this.timer) {
100
+ clearInterval(this.timer);
101
+ }
102
+ // 上报剩余数据
103
+ this.batchReport();
104
+ }
105
+ }
@@ -0,0 +1,35 @@
1
+ import { ResourceInfo } from '../types';
2
+ /**
3
+ * 资源加载监控器
4
+ */
5
+ export declare class ResourceMonitor {
6
+ private resources;
7
+ /**
8
+ * 开始监控资源加载
9
+ */
10
+ start(): void;
11
+ /**
12
+ * 采集已有资源
13
+ */
14
+ private collectExistingResources;
15
+ /**
16
+ * 观察资源加载(包括已有和新增的)
17
+ */
18
+ private observeNewResources;
19
+ /**
20
+ * 添加资源信息
21
+ */
22
+ private addResource;
23
+ /**
24
+ * 获取资源类型
25
+ */
26
+ private getResourceType;
27
+ /**
28
+ * 获取所有资源信息
29
+ */
30
+ getResources(): ResourceInfo[];
31
+ /**
32
+ * 获取慢资源(超过阈值的资源)
33
+ */
34
+ getSlowResources(threshold?: number): ResourceInfo[];
35
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 资源加载监控器
3
+ */
4
+ export class ResourceMonitor {
5
+ constructor() {
6
+ this.resources = [];
7
+ }
8
+ /**
9
+ * 开始监控资源加载
10
+ */
11
+ start() {
12
+ if (!window.performance || !window.performance.getEntriesByType) {
13
+ return;
14
+ }
15
+ // 监控资源(包括已有和新增的)
16
+ this.observeNewResources();
17
+ }
18
+ /**
19
+ * 采集已有资源
20
+ */
21
+ collectExistingResources() {
22
+ try {
23
+ const entries = window.performance.getEntriesByType('resource');
24
+ entries.forEach((entry) => {
25
+ this.addResource(entry);
26
+ });
27
+ }
28
+ catch (e) {
29
+ console.warn('Resource collection failed:', e);
30
+ }
31
+ }
32
+ /**
33
+ * 观察资源加载(包括已有和新增的)
34
+ */
35
+ observeNewResources() {
36
+ // 如果浏览器不支持 PerformanceObserver,或 observe 失败,降级到 getEntriesByType
37
+ if (!('PerformanceObserver' in window)) {
38
+ this.collectExistingResources();
39
+ return;
40
+ }
41
+ try {
42
+ const observer = new PerformanceObserver((list) => {
43
+ for (const entry of list.getEntries()) {
44
+ if (entry.entryType === 'resource') {
45
+ this.addResource(entry);
46
+ }
47
+ }
48
+ });
49
+ observer.observe({ type: 'resource', buffered: true });
50
+ }
51
+ catch (e) {
52
+ // observe 失败(如参数不支持),降级到 getEntriesByType
53
+ console.warn('Resource observer failed, fallback to getEntriesByType:', e);
54
+ this.collectExistingResources();
55
+ }
56
+ }
57
+ /**
58
+ * 添加资源信息
59
+ */
60
+ addResource(entry) {
61
+ const resourceInfo = {
62
+ name: entry.name,
63
+ type: this.getResourceType(entry.name),
64
+ duration: Math.round(entry.duration),
65
+ size: entry.transferSize || 0,
66
+ startTime: Math.round(entry.startTime),
67
+ url: entry.name,
68
+ };
69
+ this.resources.push(resourceInfo);
70
+ }
71
+ /**
72
+ * 获取资源类型
73
+ */
74
+ getResourceType(url) {
75
+ var _a;
76
+ const extension = ((_a = url.split('.').pop()) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || '';
77
+ const typeMap = {
78
+ js: 'script',
79
+ css: 'stylesheet',
80
+ png: 'image',
81
+ jpg: 'image',
82
+ jpeg: 'image',
83
+ gif: 'image',
84
+ svg: 'image',
85
+ webp: 'image',
86
+ woff: 'font',
87
+ woff2: 'font',
88
+ ttf: 'font',
89
+ eot: 'font',
90
+ xml: 'xmlhttprequest',
91
+ json: 'xmlhttprequest',
92
+ };
93
+ return typeMap[extension] || 'other';
94
+ }
95
+ /**
96
+ * 获取所有资源信息
97
+ */
98
+ getResources() {
99
+ return this.resources;
100
+ }
101
+ /**
102
+ * 获取慢资源(超过阈值的资源)
103
+ */
104
+ getSlowResources(threshold = 2000) {
105
+ return this.resources.filter((resource) => resource.duration > threshold);
106
+ }
107
+ }