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
| class PerformanceTracker {
private metrics: Map<string, number[]> = new Map()
private config: {
sampleRate: number
endpoint: string
batchSize: number
}
constructor(config: { sampleRate: number; endpoint: string; batchSize?: number }) {
this.config = {
...config,
batchSize: config.batchSize || 10
}
this.initObservers()
}
private initObservers() {
// Core Web Vitals
this.trackLCP()
this.trackINP()
this.trackCLS()
// 其他指标
this.trackFCP()
this.trackTTI()
this.trackResources()
}
private trackLCP() {
// 使用web-vitals库
import('web-vitals').then(({ onLCP }) => {
onLCP((metric) => {
this.record('LCP', metric.value)
})
})
}
private trackCLS() {
import('web-vitals').then(({ onCLS }) => {
onCLS((metric) => {
this.record('CLS', metric.value)
})
})
}
private trackINP() {
import('web-vitals').then(({ onINP }) => {
onINP((metric) => {
this.record('INP', metric.value)
})
})
}
private trackFCP() {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.record('FCP', entry.startTime)
}
}
}).observe({ entryTypes: ['paint'] })
}
private trackTTI() {
// 使用tti-polyfill
import('tti-polyfill').then(({ getTTI }) => {
getTTI().then((tti) => {
this.record('TTI', tti)
})
})
}
private trackResources() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const resource = entry as PerformanceResourceTiming
this.record(`resource_${this.getResourceType(resource.name)}`, resource.duration)
}
})
observer.observe({ entryTypes: ['resource'] })
}
private getResourceType(url: string): string {
if (url.match(/\.(js|jsx)$/)) return 'script'
if (url.match(/\.(css)$/)) return 'stylesheet'
if (url.match(/\.(png|jpg|jpeg|webp|gif|svg)$/)) return 'image'
if (url.match(/\.(woff|woff2|ttf|eot)$/)) return 'font'
return 'other'
}
private record(metric: string, value: number) {
if (!this.metrics.has(metric)) {
this.metrics.set(metric, [])
}
this.metrics.get(metric)!.push(value)
// 达到批量大小时发送
if (this.metrics.get(metric)!.length >= this.config.batchSize) {
this.flush()
}
}
private flush() {
if (this.metrics.size === 0) return
const data = Object.fromEntries(
Array.from(this.metrics.entries()).map(([key, values]) => [
key,
{
count: values.length,
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
p50: this.percentile(values, 50),
p75: this.percentile(values, 75),
p95: this.percentile(values, 95)
}
])
)
fetch(this.config.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
metrics: data
}),
keepalive: true
})
this.metrics.clear()
}
private percentile(values: number[], p: number): number {
const sorted = values.slice().sort((a, b) => a - b)
const index = Math.ceil((p / 100) * sorted.length) - 1
return sorted[index]
}
}
// 使用
const tracker = new PerformanceTracker({
sampleRate: 0.1, // 10%采样率
endpoint: 'https://analytics.example.com/performance',
batchSize: 20
})
|