前端卡顿监测工具 perfMonitor 使用与扩展
一、概要
在《前端渲染卡顿优化实践》里,我靠 console.time 和一堆 [vm-perf] 日志定位卡顿。那套埋点是临时起意写的,散落在业务代码里,每个项目、每个技术栈都要重写一遍,换个 Vue3 / React 项目就没法复用。
于是我把采集逻辑抽成一个浏览器层面的通用卡顿监测工具 perfMonitor。它不依赖任何框架——因为 Vue2、Vue3、React 共用同一条主线程和事件循环,从浏览器这一层就能统一测量。本文讲清三件事:它测什么、什么时候引入、以及想加新指标时怎么改。
二、背景:为什么在浏览器层做
排查卡顿的直觉做法是在业务代码里埋点:函数入口 console.time,出口 console.timeEnd。这在定位"哪个函数慢"时够用,但有几个局限:
- 只能测你埋了的地方。没埋到的长任务、渲染卡顿、主线程阻塞,全看不到。
- 埋点本身有偏向。你在 A 函数埋了点,就盯着 A 看,容易漏掉真正的元凶(比如渲染优化里真正的元凶是"循环里读 computed",而不是我一开始盯的表格行数)。
- 跨项目不可复用。Vue2 项目的埋点搬到 React 项目要重写。
浏览器其实已经把性能数据准备好了:PerformanceObserver 能拿到长任务、长动画帧,requestAnimationFrame 能算帧率。与其在业务里埋点,不如站在浏览器这一层统一采集。
三、它能测什么:三类体感卡顿
"卡顿"是体感,要把它拆成可量化的三类:
| 类型 | 体感 | 采集通道 | 关键字段 |
|---|---|---|---|
| 卡死(无响应) | 页面点不动、完全冻住 | long-animation-frame | blockingDuration(主线程被阻塞多久) |
| 长任务 | 短暂卡一下 | longtask | duration |
| 掉帧 / 帧率 | 动画不流畅、滚动发涩 | 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。
两个必须交代的细节
- SSR 安全。
perfMonitor的开关判断ENABLED第一行就是typeof window === 'undefined'直接返回 false,后续所有 observer / rAF / HUD 都带类型和环境守卫。所以模块顶层 import 在服务端不产生副作用。 - 按需开启。工具不是常驻的,靠开关控制: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;
}
})();
八、适用边界与注意事项
- 定位"是什么慢",不负责"为什么慢"。工具给出的是卡死、长任务、掉帧的量化结果。要找到具体是哪个函数、哪次渲染,还得配合 Performance 面板的火焰图,或者回落到业务埋点。
- 临时埋点,非线上功能。默认只服务于优化前后 A/B 对比,别把它当长期监控用——真要做线上监控,用专业的 RUM(如阿里云 ARMS、Sentry、web-vitals)更合适。
- 兼容性。
long-animation-frame是较新 API(Chrome 123+),longtask也有覆盖差异。工具里所有 observer 都包了 try/catch,不支持就静默降级:LoAF 不可用时,卡死统计回退用longtask的最大值。 - 工具自身不能影响测量。所有采集回调、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 也不会报错。