前端卡顿监测工具 perfMonitor 使用与扩展

24 分钟

一、概要

在《前端渲染卡顿优化实践》里,我靠 console.time 和一堆 [vm-perf] 日志定位卡顿。那套埋点是临时起意写的,散落在业务代码里,每个项目、每个技术栈都要重写一遍,换个 Vue3 / React 项目就没法复用。

于是我把采集逻辑抽成一个浏览器层面的通用卡顿监测工具 perfMonitor。它不依赖任何框架——因为 Vue2、Vue3、React 共用同一条主线程和事件循环,从浏览器这一层就能统一测量。本文讲清三件事:它测什么、什么时候引入、以及想加新指标时怎么改。

二、背景:为什么在浏览器层做

排查卡顿的直觉做法是在业务代码里埋点:函数入口 console.time,出口 console.timeEnd。这在定位"哪个函数慢"时够用,但有几个局限:

  1. 只能测你埋了的地方。没埋到的长任务、渲染卡顿、主线程阻塞,全看不到。
  2. 埋点本身有偏向。你在 A 函数埋了点,就盯着 A 看,容易漏掉真正的元凶(比如渲染优化里真正的元凶是"循环里读 computed",而不是我一开始盯的表格行数)。
  3. 跨项目不可复用。Vue2 项目的埋点搬到 React 项目要重写。

浏览器其实已经把性能数据准备好了:PerformanceObserver 能拿到长任务、长动画帧,requestAnimationFrame 能算帧率。与其在业务里埋点,不如站在浏览器这一层统一采集。

三、它能测什么:三类体感卡顿

"卡顿"是体感,要把它拆成可量化的三类:

类型体感采集通道关键字段
卡死(无响应)页面点不动、完全冻住long-animation-frameblockingDuration(主线程被阻塞多久)
长任务短暂卡一下longtaskduration
掉帧 / 帧率动画不流畅、滚动发涩requestAnimationFrame相邻帧间隔

三类合起来,基本覆盖了"页面卡"这个模糊描述的各个侧面。

四、核心原理:三个采集通道

1. 卡死 —— Long Animation Frames(LoAF)

LoAF 是较新的 API,报告耗时超过 50ms 的渲染帧。它最有价值的字段是 blockingDuration:这一帧里主线程被阻塞、导致用户交互无法响应的时长。这个值越大,用户越能感觉到"页面冻住了"。

new PerformanceObserver((list) => {
  for (const raw of list.getEntries()) {
    const e = raw as PerformanceEntry & { blockingDuration?: number };
    const blocking = e.blockingDuration ?? 0;
    loafs.push({ t: e.startTime, blocking });
  }
}).observe({ type: 'long-animation-frame', buffered: true });

注意这里带了 buffered: true。LoAF 支持缓存,即使工具加载晚了,也能拿到导航开始以来的历史 LoAF,补采初始加载阶段。

2. 长任务 —— longtask

longtask 报告主线程上运行超过 50ms 的单个任务。字段是 duration。

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    longTasks.push({ t: entry.startTime, d: entry.duration });
  }
}).observe({ type: 'longtask' });

一个关键区别:longtask 不支持 buffered,只能捕获观察开始之后发生的长任务。所以工具加载得越晚,漏掉的长任务越多——这正是"引入时机"重要的原因,后面单独讲。

3. 掉帧 —— requestAnimationFrame

浏览器每帧会调一次 rAF 回调。相邻两次回调的时间戳之差 d,就是这一帧的时长。60fps 的理想值是 16.67ms:

const loop = (t: number): void => {
  const d = t - last;
  last = t;
  frames.push({ t, d });
  // ...
  requestAnimationFrame(loop);
};
requestAnimationFrame(loop);

阈值定义:

  • > 33ms:掉帧(低于 30fps),用 over33Ratio 统计掉帧率
  • > 100ms:明显卡顿,用 over100Count 计数
  • > 500ms:判定为一次"卡死"(FREEZE_BLOCK_THRESHOLD)

汇总报告

采集到的原始数据按时间窗口切片,算分位数:帧时长给 p50 / p90 / p95 / p99 / max,长任务给 count / total / max / p95,卡死给 maxBlockingMs / count。分位数比平均值更能反映"最坏情况",而卡顿恰恰是最坏情况才伤体验。

五、引入时机:什么时候加载

这是使用这个工具的第一个关键点,和用 Vue 还是 React、是 SPA 还是 SSR 无关,结论是一致的:在应用挂载之前用模块顶层 import 引入,别等框架 mount 之后再动态加载。

三种加载方式,执行时机差很多:

方式执行时机能否捕获初始加载结论
挂载后动态 import(useEffect / onMounted)框架 mount 之后漏掉加载阶段长任务不推荐
模块顶层 import(入口文件顶部)打包产物求值阶段能推荐
内联 <script>HTML 解析期最能特殊需求才用

为什么时机这么要紧?回到第三节:longtask 不缓存,long-animation-frame 有 buffered 兜底。所以加载晚,longtask 数据就永久缺一段,而初始加载阶段恰恰是长任务高发期。

SPA(客户端渲染)

绝大多数人跑的是纯前端 SPA,在入口文件最顶部引入一行即可,早于应用挂载:

// Vue3:src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import './perfMonitor'   // 在 createApp().mount() 之前引入

createApp(App).mount('#app')
// React:src/main.tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './perfMonitor'   // 在 createRoot().render() 之前引入

createRoot(document.getElementById('root')!).render(<App />)

把 import './perfMonitor' 放在入口顶部、放在 createApp / createRoot 之前,采集代码就在应用挂载前开始跑。注意别把它塞进路由懒加载的 chunk 或某个 useEffect 里,那样会推迟执行时机。

SSR / SSG(Next.js / Nuxt)

带服务端渲染的框架多一步:只能放在"客户端专属"的入口里引入,否则服务端执行会报错。

  • Next.js:放在 "use client" 组件的模块顶层(不是 useEffect),再在根布局渲染这个组件。
  • Nuxt:放在 .client.js 插件,或 plugins 里用 client-only 包裹。
  • 通用兜底:工具内部有 typeof window === 'undefined' 守卫,服务端执行时直接返回 false、不做任何 DOM 操作,即使误在服务端被 import 也是安全的 no-op。

两个必须交代的细节

  1. SSR 安全。perfMonitor 的开关判断 ENABLED 第一行就是 typeof window === 'undefined' 直接返回 false,后续所有 observer / rAF / HUD 都带类型和环境守卫。所以模块顶层 import 在服务端不产生副作用。
  2. 按需开启。工具不是常驻的,靠开关控制:URL 带 ?__perf__=1,或 localStorage.__perf_monitor__ = '1' 时才激活。平时只是一段判断,几乎零开销。

如果真要从 HTML 解析期(JS 执行之前)就采集,才需要内联 <script>——那要把这段 TS 预编译成独立 JS 再内联,改动大得多,一般用不上。

六、使用方式

开启后,页面右上角会出现一个半透明的 HUD,实时显示:

  • FPS
  • 最近 1s 最大帧
  • 最长卡死
  • 长任务累计数

HUD 可以拖动,右下角有个按钮切换录制。控制台里暴露了 window.__perf:

window.__perf.start();   // 开始对某段操作(如滚动列表)分段录制
window.__perf.stop();    // 结束录制,自动打印该窗口的报告
window.__perf.report();  // 打印全量汇总(从刷新到现在)
window.__perf.clear();   // 清空已采集数据

典型用法:先 clear() 清掉加载阶段的噪声,再 start(),滚动/点击触发卡顿,stop() 看这段窗口的分位数报告。

TypeScript 侧,通过 global.d.ts 给 window.__perf 补了类型声明,返回的 PerfReport 直接从 perfMonitor 导出:

interface Window {
  __perf?: {
    start: () => void;
    stop: () => import("./lib/perfMonitor").PerfReport | undefined;
    report: (from?: number, to?: number) => import("./lib/perfMonitor").PerfReport;
    clear: () => void;
    enabled: boolean;
  };
}

七、怎么魔改:扩展其他功能

这是第二个关键点。工具的结构是"采集通道 + 缓冲 + 汇总报告",加新功能基本就是三选一:加一个 observe 通道、加一个缓冲数组、或改汇总/输出逻辑。下面给几个常见方向的改法。

1. 加 Web Vitals(LCP / CLS / INP)

性能优化绕不开 Web Vitals。加一个采集通道即可:

function observeWebVitals(): void {
  if (typeof PerformanceObserver === 'undefined') return;
  try {
    // LCP:最大内容绘制
    new PerformanceObserver((list) => {
      for (const raw of list.getEntries()) {
        const e = raw as PerformanceEntry & { startTime: number; size?: number };
        console.log('[PerfMonitor] LCP', Math.round(e.startTime), 'ms', 'size', e.size);
      }
    }).observe({ type: 'largest-contentful-paint', buffered: true });

    // CLS:累计布局偏移
    let cls = 0;
    new PerformanceObserver((list) => {
      for (const raw of list.getEntries()) {
        const e = raw as PerformanceEntry & { hadRecentInput: boolean; value: number };
        if (!e.hadRecentInput) cls += e.value;
      }
    }).observe({ type: 'layout-shift', buffered: true });

    // INP:交互到下次绘制(用 event 时序估算)
    new PerformanceObserver((list) => {
      for (const raw of list.getEntries()) {
        const e = raw as PerformanceEntry & { duration: number; name: string };
        if (e.duration > 200) console.log('[PerfMonitor] 慢交互', e.name, e.duration);
      }
    }).observe({ type: 'event', durationThreshold: 16, buffered: true });
  } catch {
    // 浏览器不支持,忽略
  }
}

在 init() 里加一句 observeWebVitals() 就行。

2. 加接口 / 资源耗时

想让监控覆盖"哪个接口慢",监听 resource 类型,过滤出网络请求:

new PerformanceObserver((list) => {
  for (const e of list.getEntries() as PerformanceResourceTiming[]) {
    if (e.initiatorType === 'fetch' || e.initiatorType === 'xmlhttprequest') {
      console.log('[PerfMonitor] 接口', e.name, Math.round(e.duration), 'ms');
    }
  }
}).observe({ type: 'resource', buffered: true });

3. 上报到服务端

工具默认只打印到控制台,适合本地排查。要长期观测,把 stop() / report() 返回的 PerfReport 序列化上报:

function upload(r: PerfReport): void {
  const body = JSON.stringify(r);
  // 页面卸载时用 sendBeacon,普通场景用 fetch 即可
  navigator.sendBeacon('/api/perf', new Blob([body], { type: 'application/json' }));
}

在 stop() 里 printReport(r) 之后补一行 upload(r) 即可。

4. 自定义 mark / measure 分段

如果想把"某段业务逻辑"也纳入监控,用浏览器原生的 performance.mark / performance.measure,再在报告里读出来:

window.__perf.mark = (name: string) => performance.mark(name);

给 PerfReport 加一个 custom: { name: string; duration: number }[] 字段,在 buildReport 里遍历 performance.getEntriesByType('measure') 过滤时间窗口即可。

5. 控制开销:采样与白名单

rAF 循环是常驻的,frames 缓冲被 FRAME_CAP(10000 条)封顶,HUD 也限频到 250ms 更新一次,避免"测量工具自己卡掉页面"。如果要在生产环境跑,建议再加采样:

const ENABLED = (() => {
  if (typeof window === 'undefined') return false;
  try {
    // 只对 5% 流量采样,或只对指定路由开启
    if (Math.random() > 0.05) return false;
    if (window.location.search.includes('__perf__')) return true;
    return window.localStorage.getItem('__perf_monitor__') === '1';
  } catch {
    return false;
  }
})();

八、适用边界与注意事项

  1. 定位"是什么慢",不负责"为什么慢"。工具给出的是卡死、长任务、掉帧的量化结果。要找到具体是哪个函数、哪次渲染,还得配合 Performance 面板的火焰图,或者回落到业务埋点。
  2. 临时埋点,非线上功能。默认只服务于优化前后 A/B 对比,别把它当长期监控用——真要做线上监控,用专业的 RUM(如阿里云 ARMS、Sentry、web-vitals)更合适。
  3. 兼容性。long-animation-frame 是较新 API(Chrome 123+),longtask 也有覆盖差异。工具里所有 observer 都包了 try/catch,不支持就静默降级:LoAF 不可用时,卡死统计回退用 longtask 的最大值。
  4. 工具自身不能影响测量。所有采集回调、HUD 更新都限频或封顶,且外层 try/catch 保证单次异常不会打断主应用,也不会打断 rAF 循环。

九、总结

  • 卡顿要拆成卡死(LoAF 的 blockingDuration)、长任务(longtask)、掉帧(rAF 帧间隔)三类来量化,分位数比平均值更能反映最坏情况。
  • 引入时机是关键:longtask 不缓存,越早加载漏得越少;LoAF 有 buffered 能补采。工程上模块顶层 import 是成本最低又足够早的方案。
  • 扩展就是加"采集通道 + 缓冲 + 汇总"三件套:加 Web Vitals、接口耗时、上报、自定义分段,都是往这三处塞东西。
  • 工具的边界:量化"卡不卡",不定位"为什么卡";是临时埋点,不是线上监控。

附录:完整代码

apps/web/lib/perfMonitor.ts:

/**
 * 本地卡顿监测工具(方案A)—— 临时埋点,用于优化前后 A/B 对比,非线上功能。
 *
 * 基于浏览器层面(PerformanceObserver / rAF)测量,跨 Vue2 / Vue3 / React 三个技术栈通用,
 * 因为三者共用同一条主线程与事件循环。
 *
 * 采集三类体感卡顿数据:
 *  1. 卡死(无响应) —— PerformanceObserver('long-animation-frame'),测「主线程被阻塞多久」
 *  2. 长任务      —— PerformanceObserver('longtask')
 *  3. 帧时长/掉帧 —— requestAnimationFrame 间隔(FPS / 掉帧率)
 *
 * 开启方式(任一):
 *  - URL 带参数 ?__perf__=1
 *  - localStorage 设置 __perf_monitor__ = '1'
 *
 * 使用:
 *  - window.__perf.start() / stop()  对某段操作(如滚动列表)分段录制,stop 自动出报告
 *  - window.__perf.report()          打印全量汇总(含刷新后到此刻,可手动覆盖加载阶段)
 *  - window.__perf.clear()           清空已采集数据
 *  - 页面右上角 HUD:实时 FPS / 最近 1s 最大帧 / 最长卡死 / 长任务数
 */

interface FrameSample {
  t: number;
  d: number;
}

interface LongTaskSample {
  t: number;
  d: number;
}

interface LoafSample {
  t: number;
  blocking: number;
}

export interface PerfReport {
  windowMs: number;
  frame: {
    count: number;
    p50: number;
    p90: number;
    p95: number;
    p99: number;
    max: number;
    over33Ratio: number;
    over100Count: number;
  };
  longTask: {
    count: number;
    total: number;
    max: number;
    p95: number;
  };
  freeze: {
    maxBlockingMs: number;
    count: number;
  };
}

// —— 是否开启 ——
const ENABLED = (() => {
  if (typeof window === 'undefined') return false;
  try {
    if (window.location.search.includes('__perf__')) return true;
    if (window.localStorage.getItem('__perf_monitor__') === '1') return true;
  } catch {
    // localStorage 可能被禁用
  }
  return false;
})();

// —— 全局采集缓冲 ——
const frames: FrameSample[] = [];
const longTasks: LongTaskSample[] = [];
const loafs: LoafSample[] = [];

let recording = false;
let sessionStart = 0;

// —— HUD 实时状态 ——
let currentFps = 0;
let maxFrame1s = 0;
let maxFreeze = 0;

const FRAME_CAP = 10000;
const FREEZE_BLOCK_THRESHOLD = 500; // 主线程阻塞超过 500ms 计为一次「卡死」

function percentile(sortedAsc: number[], p: number): number {
  if (sortedAsc.length === 0) return 0;
  const idx = Math.min(sortedAsc.length - 1, Math.floor(sortedAsc.length * p));
  return sortedAsc[idx];
}

// 2. 长任务
function observeLongTasks(): void {
  if (typeof PerformanceObserver === 'undefined') return;
  try {
    new PerformanceObserver((list) => {
      try {
        for (const entry of list.getEntries()) {
          longTasks.push({ t: entry.startTime, d: entry.duration });
        }
      } catch {
        // 单次回调异常,忽略
      }
    }).observe({ type: 'longtask' });
  } catch {
    // 浏览器不支持 longtask,忽略
  }
}

// 1. 卡死(主线程无响应):Long Animation Frames,blockingDuration 即「卡死时长」
function observeLongAnimationFrames(): void {
  if (typeof PerformanceObserver === 'undefined') return;
  try {
    new PerformanceObserver((list) => {
      try {
        for (const raw of list.getEntries()) {
          const e = raw as PerformanceEntry & {
            blockingDuration?: number;
            duration?: number;
          };
          const blocking = e.blockingDuration ?? e.duration ?? 0;
          loafs.push({ t: e.startTime, blocking });
          if (blocking > maxFreeze) maxFreeze = blocking;
        }
      } catch {
        // 单次回调异常,忽略
      }
    }).observe({ type: 'long-animation-frame', buffered: true });
  } catch {
    // LoAF 不支持,报告时回退用 longtask 的最大值
  }
}

// 3. 帧时长 + FPS
function observeFrames(): void {
  if (typeof requestAnimationFrame !== 'function') return;
  let last = performance.now();
  let fpsCount = 0;
  let fpsWindowStart = performance.now();
  let lastHudUpdate = 0;

  const loop = (t: number): void => {
    try {
      const d = t - last;
      last = t;
      frames.push({ t, d });
      if (frames.length > FRAME_CAP) frames.splice(0, frames.length - FRAME_CAP);

      fpsCount += 1;
      if (t - fpsWindowStart >= 500) {
        currentFps = Math.round((fpsCount * 1000) / (t - fpsWindowStart));
        fpsCount = 0;
        fpsWindowStart = t;
      }

      // HUD 更新限频,避免其自身 DOM 写扰动测量
      if (t - lastHudUpdate >= 250) {
        lastHudUpdate = t;

        const cutoff = t - 1000;
        let max = 0;
        for (let i = frames.length - 1; i >= 0; i -= 1) {
          const f = frames[i];
          if (f.t < cutoff) break;
          if (f.d > max) max = f.d;
        }
        maxFrame1s = max;

        updateHud();
      }
    } catch {
      // 单帧采集异常,忽略,保证 rAF 循环不中断
    }
    requestAnimationFrame(loop);
  };

  requestAnimationFrame(loop);
}

// —— HUD ——
let hud: HTMLDivElement | null = null;
let hudFps: HTMLSpanElement;
let hudFrame: HTMLSpanElement;
let hudFreeze: HTMLSpanElement;
let hudLongTask: HTMLSpanElement;
let hudBtn: HTMLButtonElement;

function mountHud(): void {
  if (typeof document === 'undefined' || !document.body) return;
  try {
    hud = document.createElement('div');
  hud.style.cssText = [
    'position:fixed',
    'top:80px',
    'right:16px',
    'z-index:99999',
    'background:rgba(17,24,39,0.9)',
    'color:#e5e7eb',
    'font:12px/1.6 Menlo,Consolas,monospace',
    'padding:10px 12px',
    'border-radius:8px',
    'box-shadow:0 4px 16px rgba(0,0,0,0.4)',
    'cursor:move',
    'user-select:none',
    'min-width:180px',
  ].join(';');

  const title = document.createElement('div');
  title.textContent = 'PERF MONITOR';
  title.style.cssText = 'font-weight:700;color:#60a5fa;margin-bottom:6px;';

  const row = (label: string): { value: HTMLSpanElement; wrap: HTMLDivElement } => {
    const wrap = document.createElement('div');
    const k = document.createElement('span');
    k.textContent = label;
    k.style.color = '#9ca3af';
    const v = document.createElement('span');
    v.style.cssText = 'float:right;font-weight:600;';
    wrap.appendChild(k);
    wrap.appendChild(v);
    return { value: v, wrap };
  };

  const fpsRow = row('FPS');
  hudFps = fpsRow.value;
  const frameRow = row('最近1s最大帧');
  hudFrame = frameRow.value;
  const freezeRow = row('最长卡死');
  hudFreeze = freezeRow.value;
  const ltRow = row('长任务累计');
  hudLongTask = ltRow.value;

  hudBtn = document.createElement('button');
  hudBtn.textContent = '开始录制';
  hudBtn.style.cssText = [
    'margin-top:8px',
    'width:100%',
    'padding:4px 0',
    'border:none',
    'border-radius:4px',
    'background:#2563eb',
    'color:#fff',
    'cursor:pointer',
    'font:inherit',
  ].join(';');
  hudBtn.addEventListener('click', () => (recording ? stop() : start()));

  hud.appendChild(title);
  hud.appendChild(fpsRow.wrap);
  hud.appendChild(frameRow.wrap);
  hud.appendChild(freezeRow.wrap);
  hud.appendChild(ltRow.wrap);
  hud.appendChild(hudBtn);

  // 拖拽
  let dragging = false;
  let offsetX = 0;
  let offsetY = 0;
  hud.addEventListener('mousedown', (e) => {
    dragging = true;
    offsetX = e.clientX - hud!.offsetLeft;
    offsetY = e.clientY - hud!.offsetTop;
    e.preventDefault();
  });
  window.addEventListener('mousemove', (e) => {
    if (!dragging || !hud) return;
    hud.style.left = `${e.clientX - offsetX}px`;
    hud.style.top = `${e.clientY - offsetY}px`;
    hud.style.right = 'auto';
  });
  window.addEventListener('mouseup', () => {
    dragging = false;
  });

    document.body.appendChild(hud);
    updateHud();
  } catch {
    hud = null;
  }
}

function updateHud(): void {
  try {
    if (!hud) return;
    hudFps.textContent = String(currentFps);
    hudFrame.textContent = `${maxFrame1s.toFixed(1)}ms`;
    hudFreeze.textContent = `${Math.round(maxFreeze)}ms`;
    hudLongTask.textContent = String(longTasks.length);
  } catch {
    // 忽略 HUD 刷新异常
  }
}

function updateHudButton(): void {
  try {
    if (!hud) return;
    hudBtn.textContent = recording ? '停止并出报告' : '开始录制';
  } catch {
    // 忽略 HUD 按钮刷新异常
  }
}

// —— 汇总报告 ——
function computeFreeze(loafsIn: LoafSample[], longTasksIn: LongTaskSample[]): PerfReport['freeze'] {
  let maxBlocking = 0;
  let count = 0;

  if (loafsIn.length) {
    for (const l of loafsIn) {
      if (l.blocking > FREEZE_BLOCK_THRESHOLD) count += 1;
      if (l.blocking > maxBlocking) maxBlocking = l.blocking;
    }
  } else {
    // LoAF 不支持时回退用 longtask
    for (const lt of longTasksIn) {
      if (lt.d > FREEZE_BLOCK_THRESHOLD) count += 1;
      if (lt.d > maxBlocking) maxBlocking = lt.d;
    }
  }

  return { maxBlockingMs: Math.round(maxBlocking), count };
}

function buildReport(from: number, to: number): PerfReport {
  const framesIn = frames.filter((f) => f.t >= from && f.t <= to);
  const longTasksIn = longTasks.filter((lt) => lt.t >= from && lt.t <= to);
  const loafsIn = loafs.filter((l) => l.t >= from && l.t <= to);

  const deltas = framesIn.map((f) => f.d).sort((a, b) => a - b);
  const ltDurations = longTasksIn.map((lt) => lt.d).sort((a, b) => a - b);
  const sum = (arr: number[]) => arr.reduce((s, v) => s + v, 0);

  return {
    windowMs: Math.round(to - from),
    frame: {
      count: deltas.length,
      p50: Math.round(percentile(deltas, 0.5) * 10) / 10,
      p90: Math.round(percentile(deltas, 0.9) * 10) / 10,
      p95: Math.round(percentile(deltas, 0.95) * 10) / 10,
      p99: Math.round(percentile(deltas, 0.99) * 10) / 10,
      max: Math.round(percentile(deltas, 1) * 10) / 10,
      over33Ratio: deltas.length
        ? Math.round((deltas.filter((d) => d > 33).length / deltas.length) * 1000) / 10
        : 0,
      over100Count: deltas.filter((d) => d > 100).length,
    },
    longTask: {
      count: ltDurations.length,
      total: Math.round(sum(ltDurations)),
      max: Math.round(percentile(ltDurations, 1)),
      p95: Math.round(percentile(ltDurations, 0.95)),
    },
    freeze: computeFreeze(loafsIn, longTasksIn),
  };
}

function printReport(r: PerfReport): void {
  try {
    console.group(`[PerfMonitor] 窗口 ${r.windowMs}ms`);
    console.log(
      `%c卡死(主线程无响应): 最长 ${r.freeze.maxBlockingMs}ms | 次数 ${r.freeze.count}`,
      r.freeze.maxBlockingMs > 2000 ? 'color:#f87171;font-weight:bold' : 'color:inherit',
    );
    console.log(
      `长任务: ${r.longTask.count} 个 | 总耗时 ${r.longTask.total}ms | 最大 ${r.longTask.max}ms | p95 ${r.longTask.p95}ms`,
    );
    console.log(
      `帧: 共 ${r.frame.count} 帧 | 单帧最大 %c${r.frame.max}ms%c | p95 ${r.frame.p95}ms | ` +
        `p99 ${r.frame.p99}ms | 掉帧(>33ms) ${r.frame.over33Ratio}% | 卡顿(>100ms) ${r.frame.over100Count}次`,
      'font-weight:bold',
      '',
    );
    console.groupEnd();
  } catch {
    // 报告打印异常忽略
  }
}

function start(): void {
  try {
    sessionStart = performance.now();
    recording = true;
    updateHudButton();
    console.log('[PerfMonitor] 开始录制,滚动/点击后调用 stop() 或点 HUD 按钮结束');
  } catch {
    // 忽略录制启动异常
  }
}

function stop(): PerfReport | undefined {
  try {
    if (!recording) {
      console.warn('[PerfMonitor] 当前未在录制,先调用 start()');
      return undefined;
    }
    recording = false;
    updateHudButton();
    const r = buildReport(sessionStart, performance.now());
    printReport(r);
    return r;
  } catch {
    recording = false;
    return undefined;
  }
}

function report(from = 0, to = performance.now()): PerfReport {
  const r = buildReport(from, to);
  printReport(r);
  return r;
}

function clear(): void {
  try {
    frames.length = 0;
    longTasks.length = 0;
    loafs.length = 0;
    maxFreeze = 0;
    console.log('[PerfMonitor] 已清空采集数据');
  } catch {
    // 忽略清空异常
  }
}

function init(): void {
  try {
    observeLongTasks();
    observeLongAnimationFrames();
    observeFrames();
    mountHud();

    window.__perf = { start, stop, report, clear, enabled: true };
    console.log(
      '%c[PerfMonitor] 已开启。滚动/点击后用 __perf.start()/__perf.stop() 分段录制,或 __perf.report() 出全量报告',
      'color:#60a5fa',
    );
  } catch {
    // 工具自身异常不得影响主应用
  }
}

if (ENABLED) {
  try {
    init();
  } catch {
    // 工具自身异常不得影响主应用
  }
}

接入示例:把上面这段代码存成 perfMonitor.ts(或 .js,去掉类型标注即可),然后在入口文件顶部引入一行。

// Vue3:src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import './perfMonitor'   // 在 createApp().mount() 之前引入

createApp(App).mount('#app')
// React:src/main.tsx
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './perfMonitor'   // 在 createRoot().render() 之前引入

createRoot(document.getElementById('root')!).render(<App />)

SSR / SSG 框架(Next.js / Nuxt)则要放在客户端专属入口:Next.js 用 "use client" 组件的模块顶层 import 并在根布局渲染该组件,Nuxt 用 .client.js 插件。工具内部有 typeof window === 'undefined' 守卫,服务端被 import 也不会报错。

南一

前端工程师,在这里整理面试知识,也记录从零做一个网站的过程。

关于本站 →

评论

加载评论中…