Performance前端监控指标

20 阅读10分钟

核心指标

核心指标主要分为三个部分,分别是加载时性能指标,运行时性能指标和用户体验优化指标

加载时性能指标:

TTFB 浏览器发送请求到收到服务器答复的时间,也就是接口响应时间

目标是小于0.6s,达标是小于1s,超过这个时间需要:

(1)和后端反馈接口,进行服务端性能优化

(2)静态资源获取时间长,需要进行压缩、CDN加速、图片格式优化获取

DCL DOM解析完+JS执行完毕,构建完完整渲染树的时间

目标是小于2s,达标是小于3s,超过这个数据需要:

(1)异步加载非核心JS(defer,async等,或者type为module等于defer)

(2)减少首屏加载体积

(3)内联关键的CSS,减少css文件下载时间,或者使用tailwindcss这样加载体积会更小

Load 资源(例如图片,JS,CSS等)加载完毕的时间

目标是小于3s,达标是小于5s

(1)压缩资源

(2)懒加载非首屏资源

(3)减少非必要资源的打包与加载

运行时性能指标:

Long Tasks 主线程执行时间超过50ms的任务,是大数据表格加载卡顿影响到的最核心指标,因为在大数据表格加载的时候通常会进行频繁的大量数据的div重建和节点渲染,这个时候主线程的任务时间就会很长导致卡顿的问题

(1) 非核心逻辑放到Web Worker执行

(2) 虚拟列表,实际只分页加载可视区域x2的内容

(3)requestIdleCallback (兼容性考虑使用setTimeout进行兜底) 在浏览器空闲时间执行非紧急的任务,比如微前端中的子应用预加载,低优先级的非首屏文件加载;以及日志收集的时候进行信息上报

3.1)必须使用timeout规定时间进行兜底,避免浏览器一直处于繁忙时间导致任务永不执行
(3.2)不应该在requestIdleCallback里面执行布局重绘等操作,如果有涉及到,优先使用requestAnimationFrame
(3.3)需要使用setTimeout兜底,因为部分浏览器不支持
document.addEventListener('DOMContentLoaded', () => {
// 方式1:requestIdleCallback(浏览器空闲时执行)
 if (window.requestIdleCallback) {
   window.requestIdleCallback(async () => {
     await prefetchApps([{ name: 'sub-app-a', entry: '//localhost:3001' }]);
   }, { timeout: 3000 }); // 3秒内未空闲则强制执行
 } else {
   // 方式2:兼容低版本浏览器,延迟2秒执行
   setTimeout(async () => {
     await prefetchApps([{ name: 'sub-app-a', entry: '//localhost:3001' }]);
   }, 2000);
 }
});

FPS 执行任务刷新的帧率,一般小于60会用户体验会感受不到卡顿

常见原因:

  1. 首屏渲染时执行长任务(如大数据解析、DOM批量操作)
  2. 重排重绘频繁,首屏过程中频繁修改DOM样式
用户体验优化指标:

LCP 最大内容绘制时间

一般这个时间针对于图片比如轮播图进行优化,对于大数据表格加载卡顿不会反馈到这个时间上,如果页面是文字表格,容易识别出来里面的一个DIV为LCP绘制时间,这个时候不太准

(1)先使用低清图占位,后续再加载高清图替换

(2)预加载部分资源文件

(3)懒加载,先加载优先重要级的首屏图片

(4)CDN从最近站点获取图片,CDN如果能走缓存那么获取图片速度更快

FCP 首次有意义内容绘制时间,计算的是文本段落、图片等内容的首次绘制时间

(1)内联关键的CSS,非核心的JS使用async和defer加载,比如一些广告、埋点统计使用的外部SDK,这些js文件的加载优先级较低,可以使用defer或者async加载后执行

1.1)具体使用defer还是async,需要关注是否文件之间有依赖顺序,有依赖关系则使用defer,没有使用async1.2defer不可滥用,因为会阻塞DOMContentLoaded的执行,大量文件使用defer会导致卡顿白屏严重

(2)优化图片加载优先级,预加载首屏第一页的图片,下拉的图片使用懒加载,并且图片使用webp等更加轻量的格式;或者直接异步加载组件,比如首页有个按钮可以点击弹出pdf的展示弹窗组件,这个时候可以对于这个组件采用异步加载、动态导入的方式进行性能优化,不过动态导入的组件都需要进行兜底处理,加载失败的case分支

(3)减少首屏HTML加载的体积,压缩、移除无用的代码

(4)使用CDN、缓存等优化资源加载时间

(5)虚拟列表避免首屏一次性加载太多内容

FP 首次内容绘制时间(无用),计算的是纯白背景骨架屏的首次绘制时间

INP(交互延迟):页面卸载前都可能产生更慢的交互,INP 是「最大值」,衡量的是整个页面生命周期内所有用户交互的最大延迟,而非某个时间点的交互

CLS 累计布局偏移时间,也是贯穿全局的一个数据

(1)添加骨架屏占位,减少偏移差

(2)预加载核心数据,减少TTFB时间导致CLS过长

还有一些是在Lighthouse可以看见的时间,都可以作为综合衡量指标,本质上和上面的优化方式一样

TTI 首次可交互时间

SI 加载速度

TBT 阻塞时间

如何监控这些指标

数据基于Doubao-seed-code生成,存在部分删改,以下是我的prompt:

【需求描述】 实现一个 监控vue后台系统的性能并及时上报 的功能,核心点有:记录当前上报的页面名字,记录打开页面的FCP/LCP/CLS/INP/TTI等指标

  • 主技术栈:Vue3 + TypeScript
  • 辅助工具/库:无需额外第三方库

【约束条件】

  • 功能约束:每次打开一个新页面的时候就会记录当前页面的性能指标并及时上报
  • 兼容性:兼容主流浏览器(Chrome/Firefox/Safari)

【输出要求】

  1. 生成完整的可以独立使用的class代码
  2. 给出该class的使用示例
// 先补充类型定义(src/types/performance.ts)
export interface PerformanceData {
  fcp?: number;
  lcp?: number;
  cls?: number;
  inp?: number;
  tti?: number;
  ttfb?: number; // 新增:首字节时间(核心指标)
}


// 核心监控类(src/utils/PerformanceMonitor.ts)
export class PerformanceMonitor {
  private performanceData: PerformanceData = {};
  private isInitialized = false;
  private reportUrl: string;
  // 存储 Observer 实例,用于销毁
  private observers: PerformanceObserver[] = [];
  // CLS 累计值(单独存储,避免多次赋值)
  private cumulativeLayoutShift = 0;
  // INP 最大值(单独存储)
  private maxInteractionDelay = 0;

  constructor(reportUrl: string) {
    this.reportUrl = reportUrl;
  }

  init() {
    if (this.isInitialized || !window.performance) return;
    this.isInitialized = true;
    this.setupPerformanceListeners();

    // 页面卸载时上报最终性能数据(CLS/INP 需最后上报)
    window.addEventListener('beforeunload', () => {
      this.performanceData.cls = Number(this.cumulativeLayoutShift.toFixed(4));
      this.performanceData.inp = this.maxInteractionDelay;
      this.reportPerformance();
      // 销毁所有 Observer
      this.observers.forEach(obs => obs.disconnect());
    });
  }

  /** 性能监听 */
  private setupPerformanceListeners() {
    if (!('PerformanceObserver' in window)) return;

    // 1. 监控 FCP(首次内容绘制)
    this.monitorFCP();
    // 2. 监控 LCP(最大内容绘制)
    this.monitorLCP();
    // 3. 监控 CLS(累积布局偏移)
    this.monitorCLS();
    // 4. 监控 INP(交互延迟)
    this.monitorINP();
    // 5. 监控 TTI/TTFB(首次可交互/首字节时间)
    this.monitorNavigationMetrics();
  }

  /** 监控 FCP(稳定不遗漏) */
  private monitorFCP() {
    const paintObserver = new PerformanceObserver((entryList) => {
      const fcpEntry = entryList.getEntries().find(
        (entry) => entry.name === 'first-contentful-paint'
      );
      if (fcpEntry) {
        this.performanceData.fcp = Number(fcpEntry.startTime.toFixed(2));
        paintObserver.disconnect(); // 拿到后销毁
        this.reportPerformance();
      }
    });
    paintObserver.observe({ type: 'paint', buffered: true });
    this.observers.push(paintObserver);
  }

  /** 监控 LCP(预查询+buffered+取最后一个+超时兜底) */
  private monitorLCP() {
    // 预查询已存在的 LCP 条目(兜底旧浏览器)
    const existingLcp = window.performance.getEntriesByType('largest-contentful-paint').at(-1);
    if (existingLcp) {
      this.performanceData.lcp = Number(existingLcp.startTime.toFixed(2));
      this.reportPerformance();
      return;
    }

    const lcpObserver = new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      const finalLcp = entries.at(-1); // 取最后一个(最终值)
      if (finalLcp) {
        this.performanceData.lcp = Number(finalLcp.startTime.toFixed(2));
        lcpObserver.disconnect();
        this.reportPerformance();
      }
    });

    try {
      lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
      this.observers.push(lcpObserver);
      // 5秒超时兜底
      setTimeout(() => {
        lcpObserver.disconnect();
      }, 5000);
    } catch (e) {
      console.warn('LCP 监控不支持', e);
    }
  }

  /** 监控 CLS(累积无用户输入的布局偏移) */
  private monitorCLS() {
    const clsObserver = new PerformanceObserver((entryList) => {
      entryList.getEntries().forEach((entry) => {
        // 排除用户输入后的布局偏移(符合 CLS 标准)
        if (!entry.hadRecentInput) {
          this.cumulativeLayoutShift += entry.value;
        }
      });
    });
    clsObserver.observe({ type: 'layout-shift', buffered: true });
    this.observers.push(clsObserver);
  }

  /** 监控 INP(取所有交互的最大延迟) */
  private monitorINP() {
    // 兼容旧版 Chrome(event 类型)和新版(interaction 类型)
    const entryTypes = ['interaction', 'event'].filter(type => 
      PerformanceObserver.supportedEntryTypes?.includes(type)
    );
    if (entryTypes.length === 0) return;

    const inpObserver = new PerformanceObserver((entryList) => {
      entryList.getEntries().forEach((entry) => {
        // 计算交互延迟(INP 核心值:processingStart - startTime)
        const delay = entry.processingStart - entry.startTime;
        // 只记录有效交互(延迟>40ms)
        if (delay > 40 && delay > this.maxInteractionDelay) {
          this.maxInteractionDelay = Math.round(delay);
        }
      });
    });

    inpObserver.observe({ entryTypes, buffered: true });
    this.observers.push(inpObserver);
  }

  /** 监控 TTI/TTFB(导航类指标) */
  private monitorNavigationMetrics() {
    const navEntries = window.performance.getEntriesByType('navigation');
    if (navEntries.length === 0) return;

    const navEntry = navEntries[0] as PerformanceNavigationTiming;
    // TTFB(首字节时间):核心指标
    this.performanceData.ttfb = Number(navEntry.responseStart - navEntry.requestStart);
    // 近似 TTI(简化版,符合中后台场景)
    this.performanceData.tti = Number(navEntry.domInteractive - navEntry.fetchStart);
    this.reportPerformance();
  }

  /** 上报性能数据(失败缓存兜底) */
  private reportPerformance() {
    if (Object.keys(this.performanceData).length === 0) return;

    this.sendReport('performance', { ...this.performanceData })
      .catch(() => {
        // 失败缓存(复用之前的逻辑)
        const cacheKey = 'perf_report_failed';
        const cache = JSON.parse(localStorage.getItem(cacheKey) || '[]');
        cache.push({ type: 'performance', data: this.performanceData, timestamp: Date.now() });
        localStorage.setItem(cacheKey, JSON.stringify(cache.slice(-50))); // 最多存50条
      });
  }

  /** 通用上报方法(返回 Promise 便于兜底) */
  private async sendReport(type: 'performance' | 'error', data: PerformanceData | ErrorData[]) {
    if (!this.reportUrl) throw new Error('上报地址未配置');

    const reportData = {
      type,
      data,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      userId: localStorage.getItem('userId') || '' // 可选:关联用户
    };

    const response = await fetch(this.reportUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(reportData),
      priority: 'low' // 低优先级,不阻塞业务
    });

    if (!response.ok) throw new Error('上报失败');
    return response;
  }

  /** 获取当前性能数据 */
  getPerformanceData(): PerformanceData {
    return { ...this.performanceData, cls: this.cumulativeLayoutShift };
  }

  /** 手动触发性能上报 */
  triggerPerformanceReport() {
    this.performanceData.cls = Number(this.cumulativeLayoutShift.toFixed(4));
    this.performanceData.inp = this.maxInteractionDelay;
    this.reportPerformance();
  }
}

对于main.ts进行全局注册+开启监听

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import { PerformanceMonitor } from './utils/PerformanceMonitor'

// 初始化性能监控
const performanceMonitor = new PerformanceMonitor('https://your-report-server.com/api/report')
performanceMonitor.init()

// 挂载到全局以便在组件中使用
declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $performanceMonitor: PerformanceMonitor
  }
}

const app = createApp(App)
app.use(ElementPlus)
app.config.globalProperties.$performanceMonitor = performanceMonitor
app.mount('#app')

如果是spa项目,需要在路由导航文件里面进行信息上报;如果是mpa项目,直接在页面的挂载文件夹里面进行信息上报

//  路由切换回调:上报上一个路由数据 → 重置 → 重新监听
const handleRouteChange = async (to: any, from: any) => {
  if (!perfMonitor || from.path === '/') return; // 首次加载不处理

  // 步骤1:上报上一个路由的性能数据
  perfMonitor.triggerPerformanceReport();

  // 步骤2:重置性能数据(为当前路由准备)
  perfMonitor.resetPerformanceData();

  // 步骤3:重新监听当前路由的 LCP/CLS/INP
  perfMonitor.reSetupPerformanceListeners();
};

知识点记录:

  1. 在LCP的实现逻辑上,因为LCP是多次更新,每次当前页面出现比之前的div更大的时候,都会触发LCP信息记录,所以需要实时上报最新的entries,而entryList.getEntries(),获取的监听对象的数据,只需要每次取最后的数据就能获取最新的LCP,也就是entries[entries.length-1]

  2. 使用额外的performanceData进行数据存储,上报的时候取最新的数据

  3. buffered:true很重要,一般初始化监听器的时间是在main.ts的setup里面,但有些情况会写在onMounted里面,这个时候正常就没办法获取监听器建立前的数据了, 比如fcp等就会丢失,但是加上这个buffered:true就能获得建立前的性能数据

  4. 所有 Observer 存入数组,页面卸载时 disconnect(),避免内存泄漏;performanceObserver存在两个时间节点,一个时间节点是数据存储后立刻销毁,比如FCP和LCP;一个是页面卸载了再销毁,比如CLS和INP

    (3.1) FCP/LCP 是「有限次触发、值稳定后不再变化」,所以拿到值就能卸载;
    (3.2) CLS/INP 是「页面全生命周期持续触发、值动态更新」,必须等到页面卸载前才能确定最终值,因此只能在 `beforeunload` 中统一销毁和上报
    
  5. PerformanceObserver 是浏览器提供的异步性能监控 API:通过 observe() 方法指定要监听的性能指标类型(如 paintlayout-shiftlargest-contentful-paint); 当浏览器产生对应类型的性能数据时,获得回调函数; 在回调中可获取entry对象(包含指标名称、耗时、触发时间等),对象上存在name/startTime/processionTime等数据

Entry Type作用监控场景
paint捕获首次绘制(FP)、首次内容绘制(FCP)首屏加载速度
navigation捕获导航指标(TTFB、DOMContentLoaded)页面加载核心指标
resource捕获资源加载(JS/CSS/ 图片 / 接口)慢资源、失败资源监控(如 CDN 图片加载超时)
longtask捕获长任务(执行时间 > 50ms 的任务)主线程阻塞、INP 差的根因定位
event捕获用户交互事件(点击 / 输入 / 滚动)计算 INP(交互至下一次绘制延迟)
largest-contentful-paint捕获最大内容绘制(LCP)首屏核心内容加载速度