Web 性能指标与监控系统
问题
常见的 Web 性能指标有哪些?如何实现一个简单的前端性能监控系统?
答案
Web 性能指标是衡量网站用户体验的关键数据。Google 提出的 Core Web Vitals(核心网页指标) 已成为行业标准,同时还有许多其他重要的性能指标需要关注。
一、核心性能指标(Core Web Vitals)
1.1 三大核心指标
| 指标 | 全称 | 含义 | 良好 | 需改进 | 差 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制时间 | ≤ 2.5s | ≤ 4s | > 4s |
| INP | Interaction to Next Paint | 交互到下一帧绘制 | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS | Cumulative Layout Shift | 累积布局偏移 | ≤ 0.1 | ≤ 0.25 | > 0.25 |
性能指标时间线全览
下图展示了各性能指标在页面加载过程中的位置关系:
从 2024 年 3 月起,Google 使用 INP(Interaction to Next Paint) 替代了 FID(First Input Delay) 作为核心指标,因为 INP 能更全面地衡量整个页面生命周期内的交互响应性。
1.2 LCP(最大内容绘制)
定义:Largest Contentful Paint(最大内容绘制) 测量的是从页面开始加载到视口内最大的图片或文本块完成渲染的时间。
计算方式
LCP 的计算遵循以下规则:
- 候选元素:浏览器在页面加载过程中会不断识别"最大内容元素"
- 时间点:记录该元素完成渲染的时间(相对于页面开始加载)
- 动态更新:如果后续有更大的元素渲染完成,LCP 值会更新
- 停止时机:用户首次交互(点击、滚动、按键)后,停止记录新的 LCP 候选
LCP 时间 = 最大内容元素渲染完成时间 - 页面开始加载时间(navigationStart)
LCP 候选元素类型
| 元素类型 | 说明 | 大小计算 |
|---|---|---|
<img> | 图片元素 | 可见区域的实际渲染尺寸 |
<image> (SVG) | SVG 中的图片 | 可见区域尺寸 |
<video> | 视频封面图 | 封面图的渲染尺寸 |
带 background-image 的块元素 | CSS 背景图 | 元素的可见尺寸 |
| 包含文本节点的块级元素 | 文本块 | 文本边界框的尺寸 |
opacity: 0的不可见元素- 覆盖整个视口的背景图(通常是背景色)
- 占位符图片(如低质量预览图 LQIP)
- 用户滚动后才进入视口的元素
// 使用 PerformanceObserver 监测 LCP
function observeLCP(callback: (value: number) => void): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
// LCP 可能多次触发,取最后一次
const lastEntry = entries[entries.length - 1] as PerformanceEntry & {
startTime: number;
element?: Element;
};
callback(lastEntry.startTime);
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
}
// 使用示例
observeLCP((lcp) => {
console.log('LCP:', lcp.toFixed(2), 'ms');
if (lcp <= 2500) {
console.log('✅ LCP 良好');
} else if (lcp <= 4000) {
console.log('⚠️ LCP 需要改进');
} else {
console.log('❌ LCP 较差');
}
});
优化方向:
- 优化服务器响应时间(TTFB)
- 使用 CDN 加速资源分发
- 预加载关键资源(
<link rel="preload">) - 优化图片(WebP/AVIF、响应式图片)
- 移除阻塞渲染的资源
1.3 INP(交互到下一帧绘制)
定义:Interaction to Next Paint(交互到下一帧绘制) 测量的是用户与页面交互(点击、触摸、键盘输入)后,浏览器响应并呈现下一帧的全部延迟时间。
计算方式
单次交互的延迟由三部分组成:
交互延迟 = 输入延迟 + 处理时间 + 呈现延迟
| 组成部分 | 英文名 | 含义 |
|---|---|---|
| 输入延迟 | Input Delay | 从用户交互到事件处理开始的等待时间 |
| 处理时间 | Processing Time | 事件处理函数的执行时间 |
| 呈现延迟 | Presentation Delay | 从处理完成到浏览器绘制下一帧的时间 |
INP 的计算规则
INP 不是简单的平均值,而是采用**第 98 百分位(P98)**的交互延迟:
// INP 计算逻辑
function calculateINP(interactions: number[]): number {
if (interactions.length === 0) return 0;
// 按延迟时间排序
const sorted = [...interactions].sort((a, b) => a - b);
// 取第 98 百分位
const p98Index = Math.floor(sorted.length * 0.98);
return sorted[p98Index] || sorted[sorted.length - 1];
}
// 示例:假设页面有 100 次交互
// 排序后:[20, 25, 30, ..., 180, 200, 250, 500] ms
// P98 = 第 98 个值 = 250ms → 这就是 INP
- 最大值容易受单次异常交互影响,不能代表整体体验
- P98 排除了极端异常情况,更能反映用户的真实体验
- 对于交互次数少的页面,会退化为最大值
INP vs FID 的区别
| 对比项 | FID(已废弃) | INP(当前标准) |
|---|---|---|
| 测量范围 | 仅第一次交互 | 整个页面生命周期所有交互 |
| 测量内容 | 仅输入延迟 | 输入延迟 + 处理时间 + 呈现延迟 |
| 计算方式 | 单次值 | 所有交互的 P98 |
| 全面性 | 差(可能首次交互很快,后续很慢) | 好(反映整体交互质量) |
// 监测所有交互的延迟
function observeINP(callback: (value: number) => void): void {
const interactions: number[] = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const interaction = entry as PerformanceEventTiming;
// 只关注有 interactionId 的事件(完整交互)
if (interaction.interactionId) {
const duration = interaction.duration;
interactions.push(duration);
}
}
});
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
// 页面卸载前计算 INP
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && interactions.length > 0) {
// INP 是第 98 百分位的交互延迟
interactions.sort((a, b) => a - b);
const p98Index = Math.floor(interactions.length * 0.98);
const inp = interactions[p98Index] || interactions[interactions.length - 1];
callback(inp);
}
});
}
优化方向:
- 减少主线程阻塞(代码分割、延迟执行)
- 使用 Web Worker 处理复杂计算
- 优化事件处理函数
- 使用
requestAnimationFrame批量更新 DOM
1.4 CLS(累积布局偏移)
定义:Cumulative Layout Shift(累积布局偏移) 测量的是页面在整个生命周期内发生的所有意外布局偏移的累积分数,反映页面的视觉稳定性。
什么是布局偏移?
当页面上已渲染的元素突然改变位置时,就会发生布局偏移。例如:
- 图片加载后撑开页面
- 广告动态插入
- 字体加载后文本回流
- 动态内容突然出现
单次布局偏移分数计算
布局偏移分数 = 影响分数 × 距离分数
| 术语 | 英文名 | 计算方式 |
|---|---|---|
| 影响分数 | Impact Fraction | 不稳定元素影响的视口面积 / 视口总面积 |
| 距离分数 | Distance Fraction | 元素移动的最大距离 / 视口高度或宽度(取较大者) |
具体计算示例
假设:
- 视口高度:800px
- 一个按钮(100px × 50px)从 y=200 移动到 y=300
// 1. 计算影响分数
const viewportHeight = 800;
const elementHeight = 50;
const moveDistance = 100; // 300 - 200
// 影响区域 = 原位置高度 + 移动距离 = 50 + 100 = 150px
const impactHeight = elementHeight + moveDistance;
const impactFraction = impactHeight / viewportHeight; // 150 / 800 = 0.1875
// 2. 计算距离分数
const distanceFraction = moveDistance / viewportHeight; // 100 / 800 = 0.125
// 3. 布局偏移分数
const layoutShiftScore = impactFraction * distanceFraction; // 0.1875 × 0.125 = 0.0234
CLS 的会话窗口计算
CLS 不是简单地累加所有布局偏移,而是采用**会话窗口(Session Window)**算法:
-
会话窗口条件:
- 两次偏移间隔 ≤ 1 秒
- 窗口总时长 ≤ 5 秒
-
计算规则:
- 将连续的布局偏移分组为会话窗口
- 计算每个窗口内的偏移总和
- CLS = 最大的会话窗口分数
- 用户交互引起的偏移:点击按钮展开内容、输入文字等(500ms 内)
- 平滑动画:使用
transform实现的动画 - 脱离文档流的元素:
position: fixed/absolute的元素移动
// 监测布局偏移
function observeCLS(callback: (value: number) => void): void {
let clsValue = 0;
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const layoutShift = entry as PerformanceEntry & {
hadRecentInput: boolean;
value: number;
};
// 忽略用户输入引起的布局偏移
if (!layoutShift.hadRecentInput) {
const firstEntry = sessionEntries[0] as PerformanceEntry | undefined;
const lastEntry = sessionEntries[sessionEntries.length - 1] as PerformanceEntry | undefined;
// 如果与上一个偏移间隔不超过 1 秒,且总时间不超过 5 秒
if (
sessionValue &&
lastEntry &&
entry.startTime - lastEntry.startTime < 1000 &&
firstEntry &&
entry.startTime - firstEntry.startTime < 5000
) {
sessionValue += layoutShift.value;
sessionEntries.push(entry);
} else {
sessionValue = layoutShift.value;
sessionEntries = [entry];
}
if (sessionValue > clsValue) {
clsValue = sessionValue;
}
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
// 页面隐藏时报告
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
callback(clsValue);
}
});
}
优化方向:
- 为图片和视频设置尺寸属性
- 为动态内容预留空间
- 避免在现有内容上方插入内容
- 使用
transform代替布局属性做动画
二、其他重要性能指标
2.1 加载相关指标
| 指标 | 全称 | 含义 | 良好 | 需改进 | 差 |
|---|---|---|---|---|---|
| TTFB | Time to First Byte | 首字节时间 | ≤ 800ms | ≤ 1.8s | > 1.8s |
| FCP | First Contentful Paint | 首次内容绘制 | ≤ 1.8s | ≤ 3s | > 3s |
| TTI | Time to Interactive | 可交互时间 | ≤ 3.8s | ≤ 7.3s | > 7.3s |
| TBT | Total Blocking Time | 总阻塞时间 | ≤ 200ms | ≤ 600ms | > 600ms |
| Speed Index | 速度指数 | 内容可见速度 | ≤ 3.4s | ≤ 5.8s | > 5.8s |
TTFB(首字节时间)
定义:Time to First Byte(首字节时间) 测量的是从浏览器发起请求到收到服务器响应的第一个字节的时间。
TTFB = responseStart - requestStart
TTFB 包含的时间:
| 阶段 | 说明 | 优化方向 |
|---|---|---|
| DNS 查询 | 域名解析 | DNS 预解析、CDN |
| TCP 连接 | 三次握手 | HTTP/2、连接复用 |
| TLS 握手 | HTTPS 加密 | TLS 1.3、会话复用 |
| 服务器处理 | 后端逻辑执行 | 缓存、数据库优化 |
| 网络传输 | 第一个字节传输 | CDN、边缘计算 |
FCP(首次内容绘制)
定义:First Contentful Paint(首次内容绘制) 测量的是浏览器首次渲染任何文本、图片、非空白 Canvas 或 SVG 的时间。
FCP = 首个内容元素渲染时间 - navigationStart
- FP(First Paint):首次绘制任何像素(包括背景色)
- FCP(First Contentful Paint):首次绘制实际内容
- FP ≤ FCP,通常 FCP 更有意义
TTI(可交互时间)
定义:Time to Interactive(可交互时间) 测量的是页面完全可交互的时间点。
TTI 的判定条件:
- FCP 已完成
- 大部分可见元素已注册事件处理程序
- 页面在 50ms 内响应用户交互
- 主线程在 5 秒内没有长任务(> 50ms)
TTI = 最后一个长任务结束后,5 秒静默窗口的起始时间
TBT(总阻塞时间)
定义:Total Blocking Time(总阻塞时间) 测量的是 FCP 到 TTI 之间,主线程被长任务阻塞的总时间。
计算方式:
- 长任务 = 执行时间 > 50ms 的任务
- 阻塞时间 = 任务执行时间 - 50ms
- TBT = 所有阻塞时间之和
// TBT 计算示例
interface Task {
duration: number;
}
function calculateTBT(tasks: Task[]): number {
const LONG_TASK_THRESHOLD = 50; // 50ms
return tasks.reduce((total, task) => {
if (task.duration > LONG_TASK_THRESHOLD) {
// 阻塞时间 = 任务时间 - 50ms
return total + (task.duration - LONG_TASK_THRESHOLD);
}
return total;
}, 0);
}
// 示例
const tasks = [
{ duration: 30 }, // 不是长任务,阻塞 0ms
{ duration: 80 }, // 长任务,阻塞 30ms (80-50)
{ duration: 120 }, // 长任务,阻塞 70ms (120-50)
{ duration: 45 }, // 不是长任务,阻塞 0ms
];
const tbt = calculateTBT(tasks); // 30 + 70 = 100ms
Speed Index(速度指数)
定义:Speed Index(速度指数) 衡量的是页面内容可见区域被填充的速度,数值越低越好。
计算方式:
- 通过视频录制页面加载过程
- 分析每一帧的视觉完成度(0-100%)
- 计算"视觉进度曲线"下方的面积
Speed Index = ∫(1 - 视觉完成度) dt
简化理解:视觉完成得越快,Speed Index 越低。
| 时间点 | 页面 A 完成度 | 页面 B 完成度 |
|---|---|---|
| 500ms | 50% | 10% |
| 1000ms | 80% | 30% |
| 1500ms | 95% | 60% |
| 2000ms | 100% | 80% |
| 2500ms | 100% | 100% |
| Speed Index | ~800ms | ~1800ms |
两个页面可能同时达到 100% 完成,但加载过程不同:
- 页面 A:快速显示主要内容 → Speed Index 低(更好)
- 页面 B:缓慢逐步显示 → Speed Index 高(更差)
interface LoadingMetrics {
ttfb: number;
fcp: number;
domContentLoaded: number;
loadComplete: number;
domNodes: number;
resourceCount: number;
transferSize: number;
}
function getLoadingMetrics(): LoadingMetrics {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const paintEntries = performance.getEntriesByType('paint');
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
const fcpEntry = paintEntries.find(entry => entry.name === 'first-contentful-paint');
return {
// TTFB:从请求开始到收到第一个字节
ttfb: navigation.responseStart - navigation.requestStart,
// FCP:首次内容绘制
fcp: fcpEntry?.startTime ?? 0,
// DOM 解析完成时间
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.startTime,
// 页面完全加载时间
loadComplete: navigation.loadEventEnd - navigation.startTime,
// DOM 节点数量
domNodes: document.querySelectorAll('*').length,
// 资源请求数量
resourceCount: resources.length,
// 总传输大小
transferSize: resources.reduce((total, r) => total + (r.transferSize || 0), 0),
};
}
// 页面加载完成后获取
window.addEventListener('load', () => {
setTimeout(() => {
const metrics = getLoadingMetrics();
console.table({
'TTFB': `${metrics.ttfb.toFixed(0)} ms`,
'FCP': `${metrics.fcp.toFixed(0)} ms`,
'DOM Ready': `${metrics.domContentLoaded.toFixed(0)} ms`,
'Load Complete': `${metrics.loadComplete.toFixed(0)} ms`,
'DOM Nodes': metrics.domNodes,
'Resources': metrics.resourceCount,
'Transfer Size': `${(metrics.transferSize / 1024).toFixed(2)} KB`,
});
}, 0);
});
2.2 资源加载指标
interface ResourceMetrics {
name: string;
type: string;
duration: number;
transferSize: number;
cached: boolean;
}
function getResourceMetrics(): ResourceMetrics[] {
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
return resources.map(r => ({
name: r.name.split('/').pop() || r.name,
type: r.initiatorType,
duration: Math.round(r.duration),
transferSize: r.transferSize,
// transferSize 为 0 表示命中缓存
cached: r.transferSize === 0 && r.decodedBodySize > 0,
}));
}
// 按加载时间排序,找出最慢的资源
function getSlowestResources(count: number = 10): ResourceMetrics[] {
return getResourceMetrics()
.sort((a, b) => b.duration - a.duration)
.slice(0, count);
}
2.3 长任务监测
interface LongTask {
startTime: number;
duration: number;
name: string;
}
function observeLongTasks(callback: (task: LongTask) => void): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
callback({
startTime: entry.startTime,
duration: entry.duration,
name: entry.name,
});
}
});
observer.observe({ type: 'longtask', buffered: true });
}
// 使用示例
observeLongTasks((task) => {
if (task.duration > 50) {
console.warn(`⚠️ 检测到长任务: ${task.duration.toFixed(0)}ms`);
}
});
三、实现性能监控系统
3.1 整体架构
3.2 完整的性能监控 SDK
/**
* 性能监控 SDK
*/
// 类型定义
interface PerformanceData {
// 页面信息
url: string;
userAgent: string;
timestamp: number;
// 核心指标
lcp?: number;
inp?: number;
cls?: number;
fcp?: number;
ttfb?: number;
// 加载指标
domReady?: number;
loadComplete?: number;
// 资源指标
resourceCount?: number;
transferSize?: number;
// 运行时指标
longTaskCount?: number;
longTaskTime?: number;
// 内存信息(如果可用)
jsHeapSize?: number;
// 自定义标记
marks?: Record<string, number>;
}
interface MonitorOptions {
// 上报地址
reportUrl: string;
// 应用标识
appId: string;
// 采样率 0-1
sampleRate?: number;
// 是否开启调试
debug?: boolean;
// 上报方式
reportMethod?: 'beacon' | 'fetch' | 'img';
// 批量上报的缓冲大小
bufferSize?: number;
// 批量上报的时间间隔(毫秒)
flushInterval?: number;
}
class PerformanceMonitor {
private options: Required<MonitorOptions>;
private data: Partial<PerformanceData> = {};
private buffer: PerformanceData[] = [];
private interactions: number[] = [];
private clsValue = 0;
private clsEntries: PerformanceEntry[] = [];
private longTaskCount = 0;
private longTaskTime = 0;
private marks: Map<string, number> = new Map();
private isInitialized = false;
constructor(options: MonitorOptions) {
this.options = {
sampleRate: 1,
debug: false,
reportMethod: 'beacon',
bufferSize: 10,
flushInterval: 5000,
...options,
};
// 采样判断
if (Math.random() > this.options.sampleRate) {
this.log('采样率过滤,不进行监控');
return;
}
this.init();
}
private init(): void {
if (this.isInitialized) return;
this.isInitialized = true;
// 初始化页面信息
this.data = {
url: location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
};
// 监听各类性能指标
this.observeLCP();
this.observeFCP();
this.observeCLS();
this.observeINP();
this.observeLongTasks();
this.observeNavigationTiming();
this.observeResourceTiming();
this.observeMemory();
// 页面隐藏时上报
this.setupReporting();
// 定时批量上报
if (this.options.bufferSize > 1) {
setInterval(() => this.flush(), this.options.flushInterval);
}
this.log('性能监控已初始化');
}
// ==================== 指标采集 ====================
private observeLCP(): void {
try {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.data.lcp = lastEntry.startTime;
this.log('LCP:', this.data.lcp);
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
} catch (e) {
this.log('LCP 监测不支持');
}
}
private observeFCP(): void {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.data.fcp = entry.startTime;
this.log('FCP:', this.data.fcp);
}
}
});
observer.observe({ type: 'paint', buffered: true });
} catch (e) {
// 降级方案
const paintEntries = performance.getEntriesByType('paint');
const fcpEntry = paintEntries.find(e => e.name === 'first-contentful-paint');
if (fcpEntry) {
this.data.fcp = fcpEntry.startTime;
}
}
}
private observeCLS(): void {
try {
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const layoutShift = entry as PerformanceEntry & {
hadRecentInput: boolean;
value: number;
};
if (!layoutShift.hadRecentInput) {
const firstEntry = sessionEntries[0];
const lastEntry = sessionEntries[sessionEntries.length - 1];
if (
sessionValue &&
lastEntry &&
entry.startTime - lastEntry.startTime < 1000 &&
firstEntry &&
entry.startTime - firstEntry.startTime < 5000
) {
sessionValue += layoutShift.value;
sessionEntries.push(entry);
} else {
sessionValue = layoutShift.value;
sessionEntries = [entry];
}
if (sessionValue > this.clsValue) {
this.clsValue = sessionValue;
this.clsEntries = [...sessionEntries];
}
}
}
this.data.cls = this.clsValue;
});
observer.observe({ type: 'layout-shift', buffered: true });
} catch (e) {
this.log('CLS 监测不支持');
}
}
private observeINP(): void {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const interaction = entry as PerformanceEventTiming;
if (interaction.interactionId) {
this.interactions.push(interaction.duration);
}
}
});
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
} catch (e) {
this.log('INP 监测不支持');
}
}
private observeLongTasks(): void {
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
this.longTaskCount++;
this.longTaskTime += entry.duration;
this.log('长任务:', entry.duration.toFixed(0), 'ms');
}
});
observer.observe({ type: 'longtask', buffered: true });
} catch (e) {
this.log('长任务监测不支持');
}
}
private observeNavigationTiming(): void {
// 等待页面加载完成
if (document.readyState === 'complete') {
this.collectNavigationTiming();
} else {
window.addEventListener('load', () => {
// 延迟收集,确保数据完整
setTimeout(() => this.collectNavigationTiming(), 0);
});
}
}
private collectNavigationTiming(): void {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (!navigation) return;
this.data.ttfb = navigation.responseStart - navigation.requestStart;
this.data.domReady = navigation.domContentLoadedEventEnd - navigation.startTime;
this.data.loadComplete = navigation.loadEventEnd - navigation.startTime;
this.log('TTFB:', this.data.ttfb);
this.log('DOM Ready:', this.data.domReady);
this.log('Load Complete:', this.data.loadComplete);
}
private observeResourceTiming(): void {
window.addEventListener('load', () => {
setTimeout(() => {
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
this.data.resourceCount = resources.length;
this.data.transferSize = resources.reduce(
(total, r) => total + (r.transferSize || 0),
0
);
this.log('资源数量:', this.data.resourceCount);
this.log('传输大小:', (this.data.transferSize / 1024).toFixed(2), 'KB');
}, 0);
});
}
private observeMemory(): void {
// Chrome 特有的 API
if ((performance as any).memory) {
const updateMemory = (): void => {
const memory = (performance as any).memory;
this.data.jsHeapSize = memory.usedJSHeapSize;
};
updateMemory();
// 定期更新
setInterval(updateMemory, 10000);
}
}
// ==================== 上报相关 ====================
private setupReporting(): void {
// 页面隐藏时上报
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.collectFinalMetrics();
this.report();
}
});
// 页面卸载时上报
window.addEventListener('pagehide', () => {
this.collectFinalMetrics();
this.report();
});
}
private collectFinalMetrics(): void {
// 计算 INP(第 98 百分位)
if (this.interactions.length > 0) {
this.interactions.sort((a, b) => a - b);
const p98Index = Math.floor(this.interactions.length * 0.98);
this.data.inp = this.interactions[p98Index] || this.interactions[this.interactions.length - 1];
}
// 收集长任务数据
this.data.longTaskCount = this.longTaskCount;
this.data.longTaskTime = this.longTaskTime;
// 收集自定义标记
if (this.marks.size > 0) {
this.data.marks = Object.fromEntries(this.marks);
}
}
private report(): void {
const reportData: PerformanceData = {
...this.data as PerformanceData,
timestamp: Date.now(),
};
this.log('上报数据:', reportData);
if (this.options.bufferSize > 1) {
this.buffer.push(reportData);
if (this.buffer.length >= this.options.bufferSize) {
this.flush();
}
} else {
this.send([reportData]);
}
}
private flush(): void {
if (this.buffer.length === 0) return;
const data = [...this.buffer];
this.buffer = [];
this.send(data);
}
private send(data: PerformanceData[]): void {
const payload = JSON.stringify({
appId: this.options.appId,
data,
});
switch (this.options.reportMethod) {
case 'beacon':
this.sendByBeacon(payload);
break;
case 'fetch':
this.sendByFetch(payload);
break;
case 'img':
this.sendByImage(payload);
break;
}
}
private sendByBeacon(payload: string): void {
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
const success = navigator.sendBeacon(this.options.reportUrl, blob);
this.log('Beacon 上报:', success ? '成功' : '失败');
} else {
this.sendByFetch(payload);
}
}
private sendByFetch(payload: string): void {
fetch(this.options.reportUrl, {
method: 'POST',
body: payload,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch((e) => {
this.log('Fetch 上报失败:', e);
});
}
private sendByImage(payload: string): void {
const img = new Image();
const encodedData = encodeURIComponent(payload);
img.src = `${this.options.reportUrl}?data=${encodedData}`;
}
// ==================== 公共 API ====================
/**
* 标记一个时间点
*/
mark(name: string): void {
const time = performance.now();
this.marks.set(name, time);
performance.mark(name);
this.log(`标记 [${name}]:`, time.toFixed(2), 'ms');
}
/**
* 测量两个标记之间的时间
*/
measure(name: string, startMark: string, endMark?: string): number | null {
try {
const endMarkName = endMark || name + '_end';
if (!endMark) {
performance.mark(endMarkName);
}
performance.measure(name, startMark, endMarkName);
const entries = performance.getEntriesByName(name, 'measure');
const duration = entries[entries.length - 1]?.duration ?? 0;
this.log(`测量 [${name}]:`, duration.toFixed(2), 'ms');
return duration;
} catch (e) {
this.log('测量失败:', e);
return null;
}
}
/**
* 获取当前收集的性能数据
*/
getMetrics(): Partial<PerformanceData> {
this.collectFinalMetrics();
return { ...this.data };
}
/**
* 手动触发上报
*/
forceReport(): void {
this.collectFinalMetrics();
this.report();
this.flush();
}
private log(...args: any[]): void {
if (this.options.debug) {
console.log('[性能监控]', ...args);
}
}
}
export default PerformanceMonitor;
3.3 使用示例
import PerformanceMonitor from './performanceMonitor';
// 初始化监控
const monitor = new PerformanceMonitor({
reportUrl: 'https://api.example.com/performance',
appId: 'my-app',
sampleRate: 0.5, // 50% 采样率
debug: true, // 开启调试日志
});
// 自定义打点
document.addEventListener('DOMContentLoaded', () => {
monitor.mark('dom_ready');
});
// 关键业务节点打点
async function fetchData(): Promise<void> {
monitor.mark('fetch_start');
const response = await fetch('/api/data');
await response.json();
monitor.mark('fetch_end');
monitor.measure('api_duration', 'fetch_start', 'fetch_end');
}
// 获取当前指标
console.log(monitor.getMetrics());
// 手动上报
monitor.forceReport();
3.4 服务端接收示例(Express)
import { Request, Response } from 'express';
interface PerformancePayload {
appId: string;
data: PerformanceData[];
}
// 接收性能数据
export function receivePerformance(req: Request, res: Response): void {
const payload: PerformancePayload = req.body;
// 数据验证
if (!payload.appId || !Array.isArray(payload.data)) {
res.status(400).json({ error: 'Invalid payload' });
return;
}
// 处理数据
for (const data of payload.data) {
// 存储到数据库
saveToDatabase(payload.appId, data);
// 异常告警
if (data.lcp && data.lcp > 4000) {
sendAlert(`LCP 过高: ${data.lcp}ms`, data.url);
}
if (data.cls && data.cls > 0.25) {
sendAlert(`CLS 过高: ${data.cls}`, data.url);
}
}
res.status(200).json({ success: true });
}
async function saveToDatabase(appId: string, data: PerformanceData): Promise<void> {
// 存储到 InfluxDB/ClickHouse/MongoDB 等时序数据库
console.log(`保存性能数据: ${appId}`, data);
}
function sendAlert(message: string, url: string): void {
// 发送告警到钉钉/企业微信/Slack
console.warn(`[告警] ${message} - ${url}`);
}
四、性能指标分析与可视化
4.1 数据聚合分析
interface PerformanceStats {
count: number;
p50: number;
p75: number;
p90: number;
p99: number;
avg: number;
}
function calculateStats(values: number[]): PerformanceStats {
if (values.length === 0) {
return { count: 0, p50: 0, p75: 0, p90: 0, p99: 0, avg: 0 };
}
const sorted = [...values].sort((a, b) => a - b);
const n = sorted.length;
return {
count: n,
p50: sorted[Math.floor(n * 0.5)],
p75: sorted[Math.floor(n * 0.75)],
p90: sorted[Math.floor(n * 0.9)],
p99: sorted[Math.floor(n * 0.99)],
avg: sorted.reduce((a, b) => a + b, 0) / n,
};
}
// 分析一段时间内的性能数据
function analyzePerformance(dataList: PerformanceData[]): Record<string, PerformanceStats> {
const lcpValues = dataList.filter(d => d.lcp).map(d => d.lcp!);
const inpValues = dataList.filter(d => d.inp).map(d => d.inp!);
const clsValues = dataList.filter(d => d.cls !== undefined).map(d => d.cls!);
const fcpValues = dataList.filter(d => d.fcp).map(d => d.fcp!);
const ttfbValues = dataList.filter(d => d.ttfb).map(d => d.ttfb!);
return {
LCP: calculateStats(lcpValues),
INP: calculateStats(inpValues),
CLS: calculateStats(clsValues),
FCP: calculateStats(fcpValues),
TTFB: calculateStats(ttfbValues),
};
}
4.2 健康评分系统
interface HealthScore {
overall: number; // 0-100
lcp: number;
inp: number;
cls: number;
details: string[];
}
function calculateHealthScore(stats: Record<string, PerformanceStats>): HealthScore {
const details: string[] = [];
// LCP 评分(权重 35%)
let lcpScore = 100;
const lcpP75 = stats.LCP?.p75 || 0;
if (lcpP75 > 4000) {
lcpScore = 30;
details.push('❌ LCP P75 > 4s,需要紧急优化');
} else if (lcpP75 > 2500) {
lcpScore = 60;
details.push('⚠️ LCP P75 > 2.5s,需要改进');
} else {
details.push('✅ LCP 表现良好');
}
// INP 评分(权重 30%)
let inpScore = 100;
const inpP75 = stats.INP?.p75 || 0;
if (inpP75 > 500) {
inpScore = 30;
details.push('❌ INP P75 > 500ms,交互响应差');
} else if (inpP75 > 200) {
inpScore = 60;
details.push('⚠️ INP P75 > 200ms,需要优化');
} else {
details.push('✅ INP 表现良好');
}
// CLS 评分(权重 35%)
let clsScore = 100;
const clsP75 = stats.CLS?.p75 || 0;
if (clsP75 > 0.25) {
clsScore = 30;
details.push('❌ CLS P75 > 0.25,视觉稳定性差');
} else if (clsP75 > 0.1) {
clsScore = 60;
details.push('⚠️ CLS P75 > 0.1,需要改进');
} else {
details.push('✅ CLS 表现良好');
}
const overall = Math.round(lcpScore * 0.35 + inpScore * 0.3 + clsScore * 0.35);
return {
overall,
lcp: lcpScore,
inp: inpScore,
cls: clsScore,
details,
};
}
五、上报策略
5.1 上报方式对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| sendBeacon | 页面卸载时可靠、异步不阻塞 | POST 数据有大小限制 | 推荐,首选方案 |
| fetch + keepalive | 支持更多配置 | 部分浏览器不支持 keepalive | Beacon 降级方案 |
| Image | 兼容性最好 | 只能 GET、数据有长度限制 | 老浏览器兼容 |
| XMLHttpRequest | 兼容性好 | 页面卸载时可能被取消 | 非关键数据 |
5.2 采样策略
interface SamplingConfig {
// 基础采样率
baseRate: number;
// 性能异常时提高采样率
errorRate: number;
// 新用户采样率
newUserRate: number;
}
function shouldSample(config: SamplingConfig): boolean {
const random = Math.random();
// 新用户优先采样
const isNewUser = !localStorage.getItem('returning_user');
if (isNewUser && random < config.newUserRate) {
localStorage.setItem('returning_user', 'true');
return true;
}
// 性能差的设备提高采样率
const hardwareConcurrency = navigator.hardwareConcurrency || 2;
if (hardwareConcurrency <= 2 && random < config.errorRate) {
return true;
}
// 基础采样
return random < config.baseRate;
}
六、常见问题排查
6.1 指标异常排查流程
6.2 常见优化措施
| 指标 | 优化措施 |
|---|---|
| LCP | 预加载关键资源、使用 CDN、优化图片、SSR/SSG |
| INP | 代码分割、减少主线程阻塞、使用 Web Worker |
| CLS | 设置图片尺寸、预留广告位空间、字体加载优化 |
| FCP | 内联关键 CSS、延迟非关键 JS、骨架屏 |
| TTFB | 使用 CDN、优化服务端响应、HTTP/2 |
常见面试问题
Q1: Core Web Vitals 包含哪些指标?各代表什么意义?
答案:
Core Web Vitals 是 Google 提出的核心用户体验指标:
| 指标 | 全称 | 衡量维度 | 良好标准 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 加载性能 | ≤ 2.5s |
| INP | Interaction to Next Paint | 交互响应性 | ≤ 200ms |
| CLS | Cumulative Layout Shift | 视觉稳定性 | ≤ 0.1 |
// 使用 web-vitals 库测量
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(metric => console.log('LCP:', metric.value));
onINP(metric => console.log('INP:', metric.value));
onCLS(metric => console.log('CLS:', metric.value));
Q2: 如何实现一个简单的性能监控上报?
答案:
class SimpleMonitor {
private data: Record<string, number> = {};
constructor(private reportUrl: string) {
this.observeMetrics();
this.setupReporting();
}
private observeMetrics(): void {
// FCP
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.data.fcp = entry.startTime;
}
}
}).observe({ type: 'paint', buffered: true });
// LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
this.data.lcp = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
}
private setupReporting(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.report();
}
});
}
private report(): void {
const payload = JSON.stringify(this.data);
navigator.sendBeacon(this.reportUrl, payload);
}
}
Q3: sendBeacon 和普通 AJAX 有什么区别?为什么性能上报要用它?
答案:
| 特性 | sendBeacon | AJAX (XMLHttpRequest/fetch) |
|---|---|---|
| 页面卸载时发送 | ✅ 可靠发送 | ❌ 可能被取消 |
| 阻塞页面关闭 | ❌ 异步,不阻塞 | 同步会阻塞 |
| 返回值 | 仅返回是否成功加入队列 | 返回完整响应 |
| 请求方式 | 仅 POST | 支持所有方法 |
为什么用 sendBeacon:
- 页面关闭时能可靠地完成上报
- 不阻塞页面卸载流程,不影响用户体验
- 浏览器会在后台自动完成请求
// 页面隐藏时使用 sendBeacon
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
const data = new Blob([JSON.stringify(metrics)], { type: 'application/json' });
navigator.sendBeacon('/api/report', data);
}
});
Q4: 如何区分 FCP、LCP、FMP?
答案:
| 指标 | 含义 | 测量对象 |
|---|---|---|
| FCP | First Contentful Paint | 第一个 DOM 内容渲染(文本、图片等) |
| LCP | Largest Contentful Paint | 视口中最大元素渲染 |
| FMP | First Meaningful Paint | 主要内容渲染(已废弃,难以标准化) |
时间线:
TTFB → FP → FCP → LCP
↓ ↓ ↓
白色 首内容 最大内容
FMP 被废弃的原因:每个页面的"有意义内容"定义不同,无法标准化测量,LCP 是更好的替代方案。
Q5: CLS 是如何计算的?如何降低 CLS?
答案:
计算方式:
CLS = 影响分数(Impact Fraction) × 距离分数(Distance Fraction)
- 影响分数:不稳定元素影响的视口面积比例
- 距离分数:元素移动的最大距离占视口的比例
降低 CLS 的方法:
<!-- 1. 图片和视频设置尺寸 -->
<img src="photo.jpg" width="800" height="600" alt="Photo" />
<!-- 2. 使用 aspect-ratio -->
<style>
.video-container {
aspect-ratio: 16 / 9;
width: 100%;
}
</style>
<!-- 3. 为动态内容预留空间 -->
<style>
.ad-container {
min-height: 250px;
}
</style>
<!-- 4. 字体加载优化 -->
<style>
@font-face {
font-family: 'MyFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}
</style>
Q6: 性能监控的采样率应该设置多少?
答案:
采样率取决于流量规模和数据分析需求:
| 日 PV | 建议采样率 | 说明 |
|---|---|---|
| < 1万 | 100% | 数据量小,全量采集 |
| 1万-10万 | 30%-50% | 适当采样 |
| 10万-100万 | 10%-20% | 控制数据量 |
| > 100万 | 1%-5% | 大流量需要低采样 |
分层采样策略:
function getSampleRate(): number {
// 新用户/付费用户:100% 采样
if (isNewUser() || isPremiumUser()) return 1;
// 性能异常:提高采样率
if (isSlowDevice()) return 0.5;
// 普通用户:基础采样率
return 0.1;
}
相关链接
- Web Vitals - Google 官方指南
- web-vitals 库 - 官方测量库
- MDN - Performance API
- Chrome DevTools - Performance
- PageSpeed Insights - 在线性能测试