跳到主要内容

Web 性能指标与监控系统

问题

常见的 Web 性能指标有哪些?如何实现一个简单的前端性能监控系统?

面试速答版

常见的 Web 性能指标有哪些?分别衡量什么? Core Web Vitals 三件套是必背的:

  • LCP 最大内容绘制:首屏最大块(通常是首图或大标题)出现的时间,衡量「加载快不快」,良好 ≤ 2.5s。
  • INP 交互到下一帧(2024 取代 FID):观察整个页面生命周期中的点击、触摸和键盘交互,通常报告最慢交互;交互很多时按规则忽略少量极端值。它衡量「点了以后多久出现下一帧反馈」,良好 ≤ 200ms。
  • CLS 累积布局偏移:页面元素跳动幅度的累加值,衡量「视觉稳不稳」,良好 ≤ 0.1。
  • 辅助指标:TTFB(首字节)、FCP(首次内容绘制)、TBT(实验室中的主线程阻塞代理指标)。TTI 适合阅读旧报告,不再作为当前核心优化目标。

怎么实现一个性能监控系统? 核心是「采集 → 上报 → 聚合 → 告警」四步:

  • 采集:生产环境优先使用官方 web-vitals(需要定位原因时使用 attribution 构建)拿到 LCP/INP/CLS;其他诊断数据再用 PerformanceObserver 监听 paintnavigationresourcelong-animation-frame 等类型。
  • 上报:用 navigator.sendBeacon 上报,页面关闭也不丢;按采样率(如 10%)控制成本。
  • 聚合:后端按页面/版本/设备分维度聚合,看 P75 而不是均值(Google 标准)。
  • 告警:核心指标退化超过阈值就钉钉/企微告警;接 Lighthouse CI 在 PR 阶段就拦下劣化。
  • 结合错误监控(Sentry)和会话回放(Replay),能直接定位是哪个组件、哪个用户的问题。

答案

Web 性能指标是衡量网站用户体验的关键数据。Google 提出的 Core Web Vitals(核心网页指标) 已成为行业标准,同时还有许多其他重要的性能指标需要关注。


一、核心性能指标(Core Web Vitals)

1.1 三大核心指标

指标全称含义良好需改进
LCPLargest Contentful Paint最大内容绘制时间≤ 2.5s≤ 4s> 4s
INPInteraction to Next Paint交互到下一帧绘制≤ 200ms≤ 500ms> 500ms
CLSCumulative Layout Shift累积布局偏移≤ 0.1≤ 0.25> 0.25

性能指标时间线全览

下图展示了各性能指标在页面加载过程中的位置关系:

INP 替代 FID

从 2024 年 3 月起,Google 使用 INP(Interaction to Next Paint) 替代了 FID(First Input Delay) 作为核心指标,因为 INP 能更全面地衡量整个页面生命周期内的交互响应性。

1.2 LCP(最大内容绘制)

定义Largest Contentful Paint(最大内容绘制) 测量的是从页面开始加载到视口内最大的图片或文本块完成渲染的时间。

计算方式

LCP 的计算遵循以下规则:

  1. 候选元素:浏览器在页面加载过程中会不断识别"最大内容元素"
  2. 时间点:记录该元素完成渲染的时间(相对于页面开始加载)
  3. 动态更新:如果后续有更大的元素渲染完成,LCP 值会更新
  4. 停止时机:用户首次交互(点击、滚动、按键)后,停止记录新的 LCP 候选
LCP 时间 = 最大内容元素渲染完成时间 - 页面开始加载时间(navigationStart)

LCP 候选元素类型

元素类型说明大小计算
<img>图片元素可见区域的实际渲染尺寸
<image> (SVG)SVG 中的图片可见区域尺寸
<video>视频封面图封面图的渲染尺寸
background-image 的块元素CSS 背景图元素的可见尺寸
包含文本节点的块级元素文本块文本边界框的尺寸
不计入 LCP 的元素
  • opacity: 0 的不可见元素
  • 覆盖整个视口的背景图(通常是背景色)
  • 占位符图片(如低质量预览图 LQIP)
  • 用户滚动后才进入视口的元素
measureLCP.ts
// 使用 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 最容易被误解成“把每个事件耗时放进数组后直接取 P98”。实际计算还有三层含义:

  1. 一次逻辑交互可能包含 pointerdownpointerupclick 等多个事件,应按 interactionId 分组,取这组事件中最长的 duration,而不是重复统计每个事件。
  2. 对大多数页面,INP 就是页面生命周期中最慢的那次交互;当交互很多时,每 50 次交互忽略一个最高异常值,效果上接近高分位数。
  3. 判断整个站点是否达标时,再按页面访问样本聚合,分别查看移动端和桌面端的 P75
不要在生产环境重新实现 INP

Event Timing 还涉及低于阈值的事件、BFCache 恢复、页面隐藏、iframe 和指标最终值等边界。生产环境应使用官方 web-vitals,手写 PerformanceObserver 更适合学习 API 或采集诊断明细。

INP vs FID 的区别

对比项FID(已废弃)INP(当前标准)
测量范围仅第一次交互整个页面生命周期所有交互
测量内容仅输入延迟输入延迟 + 处理时间 + 呈现延迟
计算方式第一次交互的输入延迟全生命周期最慢或接近最慢的逻辑交互
全面性差(可能首次交互很快,后续很慢)好(反映整体交互质量)
measureINP.ts
import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
// value 是最终或当前 INP;attribution 给出慢交互目标和三个阶段耗时
console.log({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
target: metric.attribution.interactionTarget,
inputDelay: metric.attribution.inputDelay,
processingDuration: metric.attribution.processingDuration,
presentationDelay: metric.attribution.presentationDelay,
});
}, { reportAllChanges: true });

优化方向

  • 减少主线程阻塞(代码分割、延迟执行)
  • 使用 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. 会话窗口条件

    • 两次偏移间隔 ≤ 1 秒
    • 窗口总时长 ≤ 5 秒
  2. 计算规则

    • 将连续的布局偏移分组为会话窗口
    • 计算每个窗口内的偏移总和
    • CLS = 最大的会话窗口分数
不计入 CLS 的布局偏移
  • 用户交互引起的偏移:点击按钮展开内容、输入文字等(500ms 内)
  • 平滑动画:使用 transform 实现的动画
  • 脱离文档流的元素position: fixed/absolute 的元素移动
measureCLS.ts
// 监测布局偏移
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 加载相关指标

指标全称含义良好需改进
TTFBTime to First Byte首字节时间≤ 800ms≤ 1.8s> 1.8s
FCPFirst Contentful Paint首次内容绘制≤ 1.8s≤ 3s> 3s
TTI(旧版)Time to Interactive历史报告中的可交互时间不再作为当前核心门禁
TBTTotal Blocking Time总阻塞时间≤ 200ms≤ 600ms> 600ms
Speed Index速度指数内容可见速度≤ 3.4s≤ 5.8s> 5.8s

TTFB(首字节时间)

定义Time to First Byte(首字节时间) 测量的是从浏览器发起请求到收到服务器响应的第一个字节的时间。

TTFB = responseStart - startTime

requestStart 只表示开始发送当前请求的时间,会漏掉重定向、DNS、连接和 TLS 等导航阶段;诊断服务器处理耗时时可以再单独观察 responseStart - requestStart,但不要把它命名为完整页面 TTFB。

TTFB 包含的时间

阶段说明优化方向
DNS 查询域名解析DNS 预解析、CDN
TCP 连接三次握手HTTP/2、连接复用
TLS 握手HTTPS 加密TLS 1.3、会话复用
服务器处理后端逻辑执行缓存、数据库优化
网络传输第一个字节传输CDN、边缘计算

FCP(首次内容绘制)

定义First Contentful Paint(首次内容绘制) 测量的是浏览器首次渲染任何文本、图片、非空白 Canvas 或 SVG 的时间。

FCP = 首个内容元素渲染时间 - navigationStart
FCP vs FP
  • FP(First Paint):首次绘制任何像素(包括背景色)
  • FCP(First Contentful Paint):首次绘制实际内容
  • FP ≤ FCP,通常 FCP 更有意义

TTI(可交互时间,旧版实验室指标)

定义Time to Interactive(可交互时间) 测量的是页面完全可交互的时间点。

当前应该怎么看 TTI?

TTI 仍有助于理解旧版 Lighthouse 报告,但它依赖“网络和主线程静默窗口”,对长生命周期应用不够稳定。当前实践通常用现场 INP 衡量真实交互体验,用实验室 TBT、长任务和 Long Animation Frames 定位主线程阻塞,不再把 TTI 当作核心门禁指标。

TTI 的判定条件

  1. FCP 已完成
  2. 大部分可见元素已注册事件处理程序
  3. 页面在 50ms 内响应用户交互
  4. 主线程在 5 秒内没有长任务(> 50ms)
TTI = 最后一个长任务结束后,5 秒静默窗口的起始时间

TBT(总阻塞时间)

定义Total Blocking Time(总阻塞时间) 测量的是实验室加载过程中,从 FCP 到达到可交互状态之间,主线程被长任务阻塞的总时间。它可作为 INP 的实验室代理信号,但不能替代真实交互数据。

计算方式

  • 长任务 = 执行时间 > 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 完成度
500ms50%10%
1000ms80%30%
1500ms95%60%
2000ms100%80%
2500ms100%100%
Speed Index~800ms~1800ms
Speed Index 的意义

两个页面可能同时达到 100% 完成,但加载过程不同:

  • 页面 A:快速显示主要内容 → Speed Index 低(更好)
  • 页面 B:缓慢逐步显示 → Speed Index 高(更差)
measureLoadingMetrics.ts
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 资源加载指标

measureResourceTiming.ts
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 长任务监测

measureLongTasks.ts
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

performanceMonitor.ts
/**
* 性能监控 SDK
*/
import { onINP } from 'web-vitals/attribution';

// 类型定义
interface PerformanceData {
// 页面信息
url: string;
userAgent: string;
timestamp: number;

// 核心指标
lcp?: number;
inp?: number;
cls?: number;
fcp?: number;
ttfb?: number;
metricIds?: Partial<Record<'INP', string>>;
navigationType?: string;
interactionTarget?: string;

// 加载指标
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 clsValue = 0;
private clsEntries: PerformanceEntry[] = [];
private longTaskCount = 0;
private longTaskTime = 0;
private marks: Map<string, number> = new Map();
private isInitialized = false;
private lastFinalReportAt = 0;

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 {
onINP((metric) => {
this.data.inp = metric.value;
this.data.metricIds = { ...this.data.metricIds, INP: metric.id };
this.data.navigationType = metric.navigationType;
this.data.interactionTarget = metric.attribution.interactionTarget;
this.log('INP:', metric.value, metric.attribution);
}, { reportAllChanges: true });
}

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;

// 从当前导航的 time origin 起算,包含重定向、Service Worker 等前置阶段
this.data.ttfb = navigation.responseStart - navigation.startTime;
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 {
const finalizeAndReport = (): void => {
// visibilitychange 和 pagehide 可能紧接着触发,避免重复发送同一批数据
const now = Date.now();
if (now - this.lastFinalReportAt < 500) return;
this.lastFinalReportAt = now;

this.collectFinalMetrics();
this.report();
// 离开页面时不能继续等待 bufferSize 或定时器
this.flush();
};

document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') finalizeAndReport();
});

window.addEventListener('pagehide', finalizeAndReport);
}

private collectFinalMetrics(): void {
// 收集长任务数据
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 使用示例

使用性能监控SDK
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)

server/performanceController.ts
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 数据聚合分析

performanceAnalysis.ts
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 健康评分系统

healthScore.ts
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支持更多配置部分浏览器不支持 keepaliveBeacon 降级方案
Image兼容性最好只能 GET、数据有长度限制老浏览器兼容
XMLHttpRequest兼容性好页面卸载时可能被取消非关键数据

5.2 采样策略

samplingStrategy.ts
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

6.3 上报哪些上下文,才能真正定位问题?

只上报一个 LCP = 4200 很难行动。生产数据至少应包含以下维度:

维度推荐字段作用
指标身份nameidvaluedeltarating去重并区分一次页面访问中的指标变化
页面路由模板、页面类型、软/硬导航避免把带 ID 的 URL 直接作为高基数字段
发布应用版本、构建 ID、灰度组判断退化是否由某次发布引入
环境设备档位、网络类型、视口、地区找到真正受影响的用户群
归因LCP 元素/URL、INP target 与三阶段、CLS sources从“哪个指标差”推进到“为什么差”
链路traceId、Server-Timing、接口阶段关联前端、边缘节点和服务端耗时
隐私与基数

不要直接上报输入内容、完整 DOM、用户 token 或带敏感查询参数的 URL。元素目标应脱敏并限制长度;路由、版本、设备等字段要控制基数,否则时序数据库成本会快速膨胀。

6.4 BFCache、预渲染和 SPA 导航

  • 页面从 BFCache 恢复时,用户把它视为一次新的访问,指标也需要按新的访问周期处理;不要沿用恢复前的 INP。
  • 预渲染页面的时间原点和激活时机不同,应保留 navigationType,避免把预渲染等待时间算成用户可见加载时间。
  • SPA 路由切换长期以来不是传统导航,应用可以先用路由生命周期和业务标记补充监控。浏览器 Soft Navigation API 正在逐步落地,接入时应特性检测并记录浏览器版本。
  • 同一个指标可能因 reportAllChanges 多次上报,后端应按 metric id 取最终值,而不是把每次变化当成独立页面样本。

常见面试问题

Q1: Core Web Vitals 包含哪些指标?各代表什么意义?

答案

Core Web Vitals 是 Google 提出的核心用户体验指标:

指标全称衡量维度良好标准
LCPLargest Contentful Paint加载性能≤ 2.5s
INPInteraction to Next Paint交互响应性≤ 200ms
CLSCumulative 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 有什么区别?为什么性能上报要用它?

答案

特性sendBeaconAJAX (XMLHttpRequest/fetch)
页面隐藏时排队发送适合小型遥测,但不保证服务端已接收普通请求可能被生命周期取消;keepalive 可改善
阻塞页面关闭❌ 异步,不阻塞同步会阻塞
返回值仅返回是否成功加入队列返回完整响应
请求方式仅 POST支持所有方法

为什么用 sendBeacon

  1. 页面隐藏时更适合把小型遥测交给浏览器排队
  2. 不阻塞页面卸载流程,不影响用户体验
  3. 不需要等待响应;返回值只表示是否成功入队,不表示服务端已保存
// 页面隐藏时使用 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?

答案

指标含义测量对象
FCPFirst Contentful Paint第一个 DOM 内容渲染(文本、图片等)
LCPLargest Contentful Paint视口中最大元素渲染
FMPFirst 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;
}

相关链接