稀土掘金技术社区

【性能监控】别只做工具人了!手把手带你写一个前端性能检测SDK

img_v3_02t9_51022880-c5f3-4f3c-8892-b6f6ca1ba8eg.png(✨年终技术征文火热进行中,速戳上图了解详情🔍)

❝

你是不是也好奇:不装第三方库,靠浏览器自带的 API,怎么自己写一个性能监控 SDK? 咱不背术语,讲实在的,把黑盒拆开看真相:

  • 「抓数据」:搞懂 FP/FCP/LCP/CLS/INP 这些到底是什么,并利用 PerformanceObserver API 把它们一个个从浏览器底层“揪出来”。

  • 「找原因」:指标慢了不只给个分,还能说清到底慢在哪:哪个 DOM、哪条资源、哪段脚本。

  • 「搞定 SPA」:页面不刷新切路由,也能分段统计,数据不打架。

Google 官方推出的 web-vitals 库本身很棒,能一键采集 Core Web Vitals(核心 Web 指标)。但本文想带你「跳出“调包侠”的舒适区」,从零手撸一个可控、可归因、可扩展的SDK:数据来源透明、算法可调、上报可定制。

❞

读完这篇,你将收获什么?

手写 SDK 不只是为了“造轮子”,更是为了「把黑盒变成白盒」。你将深入掌握:

  1. 「拒绝盲猜」:别光听过没用过!带你用原生 API (PerformanceObserver) 亲自抓数据,彻底搞懂数据从哪冒出来的。
  2. 「全身体检」:不仅看页面加载快不快 (LCP),还看点得顺不顺 (INP)、动得乱不乱 (CLS),给应用做个全套 CT。
  3. 「工程架构」:代码怎么写才不乱?教你把采集、处理、上报、配置拆得明明白白,想加新功能随时加。
  4. 「直击病灶」:光知道“慢”没用,得知道“为什么慢”。精准定位导致卡顿的 「DOM 元素」,并能区分长任务是来自「主线程」还是「第三方 iframe」。

预备知识:别被指标缩写吓跑了

别被那一堆性能指标缩写整蒙了。对于前端来说,核心就关注三件事:「快不快(Loading)」、「卡不卡(Interaction)」、「稳不稳(Visual Stability)」。

1. Loading (加载):别让我等太久!

用户最怕面对一片白屏。这个阶段我们关注三个瞬间:

指标 (全称)
解释
典型场景
「FP」
 (First Paint)
首次绘制
「屏幕亮了」

浏览器开始渲染任何东西的时刻(哪怕只是背景色)。
屏幕从纯白变成浅灰色,虽然啥内容都没有,但你知道“它活着”。
「FCP」
 (First Contentful Paint)
首次内容绘制
「看到内容了」

浏览器渲染出第一个内容(文字、图片、Logo)的时刻。
页面上终于蹦出了“Loading...”文字或者导航栏 Logo。
「LCP」
 (Largest Contentful Paint)
最大内容绘制
「主角登场」

视口内可见的最大图片或文本块渲染完成的时刻。这是 Google 最看重的加载指标。
淘宝详情页的大图终于刷出来了,你终于看清商品长啥样了。
❝

「⏱️ 及格线」:FCP < 1.8s,LCP < 2.5s

❞

2. Interaction (交互):别卡得像 PPT!

东西加载出来了,用户开始点了,这时候最怕卡顿。

指标 (全称)
解释
典型场景
「FID」
 (First Input Delay)
首次输入延迟
「第一下没反应?」

用户第一次与页面交互(点击按钮、链接)到浏览器真正开始处理这个事件的时间差。
兴奋地去点“登录”按钮,结果点了没反应,过了 1 秒钟按钮才变色。
「INP」
 (Interaction to Next Paint)
下次绘制交互
「越用越卡?」

FID 的升级版。它不仅看第一下,还看你浏览全程中所有交互的延迟,取最慢的那几次。
每输入一个字,输入框都要卡顿一下才能显示出来,有种“粘滞感”。
「Long Task」

长任务
「谁在堵路?」

任何执行时间超过 50ms 的 JavaScript 任务。
主线程就像单行道,大卡车(复杂计算)一堵,后面的点击事件全得排队。
❝

「⏱️ 及格线」:FID < 100ms,INP < 200ms,Long Task < 50ms

❞

「注」:长任务不光交互期会出现,加载时也常见;它会让白屏更久、把 TBT(总阻塞时间)拉高。我们归到“交互”里讲,是因为它最直接拖慢的是点击/输入的响应(FID、INP)。

3. Visual Stability (视觉稳定性):别乱动!

这可能是最让人抓狂的体验。

指标 (全称)
解释
典型场景
「CLS」
 (Cumulative Layout Shift)
累积布局偏移
「手滑点错了!」

页面布局在加载过程中发生意外移动的程度。
刚想点“取消”,顶部广告突然插队把页面往下挤,害你点成了“支付”。分数越低,页面越稳。
❝

「⏱️ 及格线」:CLS < 0.1

❞

系统架构与功能设计

为了保证 SDK 的轻量性与可扩展性,我们采用了「分层架构设计」。整个系统由 「核心采集层」、「数据处理层」、「数据上报层」 和 「配置中心」 四大模块组成。

简单说,就是把活儿分得明明白白:「采集模块只管抓数据,处理模块只管处理数据,上报只管发送数据到服务端,配置只管配置」

image.png

「架构要点:」

  • 「采集层」:Loading / Interaction / VisualStability / Network(各司其职)
  • 「处理层」:数据清洗、格式化、核心指标归因(找到卡顿的 DOM 或脚本)
  • 「上报层」:支持 sendBeacon + fetch keepalive 双保险,确保数据不丢失
  • 「配置中心」:环境区分、采样率控制、日志开关

1. 核心采集层 (Collectors) —— 用户体验四步走

这是 SDK 的心脏。咱们不按技术分类,按「用户实际感受」来分,对应源码的四个核心目录:

  • 「Step 1: Loading (看得见吗?)」

    • 「核心目标」:紧盯白屏时间与关键内容渲染。
    • 「实现手段」:利用 Paint Timing 与 Largest Contentful Paint API,捕获 FP、FCP、LCP 及页面加载完成时机。
  • 「Step 2: Interaction (好用吗?)」

    • 「核心目标」:监控用户交互的响应速度与流畅度。
    • 「实现手段」:通过 Event Timing 监听点击延迟(FID/INP),并用 Long Task API 揪出导致主线程阻塞的元凶。
  • 「Step 3: Visual Stability (稳不稳?)」

    • 「核心目标」:防止页面布局“乱动”,提升视觉舒适度。
    • 「实现手段」:结合 Layout Shift API 计算布局偏移(CLS),并针对 SPA 应用做特殊的会话窗口计算。
  • 「Step 4: Network (为啥慢?)」

    • 「核心目标」:定位资源加载与接口响应的瓶颈。
    • 「实现手段」:复用 Resource Timing 接口,深度解析静态资源与 XHR/Fetch 请求的 DNS、TCP、TTFB 等关键耗时。

2. 数据处理层 (Processor) —— 数据清洗工

  • 「洗数据」:浏览器原生的 API 给的数据太杂太乱,咱们得把它洗成干净统一的 JSON 格式,方便后端存。
  • 「加料」:光知道卡了没用,还得知道「哪儿卡了」。比如 LCP 慢,我们会自动把那个慢元素的 DOM 选择器加上;长任务卡,我们会尝试解析出是哪个脚本干的。

3. 数据上报层 (Reporter) —— 快递员

  • 「使命必达」:用户都要关页面了,数据还没发出去?首选 Navigator.sendBeacon(兼容性好);在现代浏览器中也可使用 fetch(..., { keepalive: true }) 以支持自定义 Header
  • 「省流模式」:平时不重要的日志先攒一攒(批量上报),关键的报错立刻发(实时上报),给用户的流量省点钱。

4. 配置中心 (Configurator) —— 遥控器

  • 「随心所欲」:通过 options 参数控制。开发环境想看日志?开!生产环境只报错误?关!采样率设多少?你说了算。

「核心代码实现」

项目结构

为了保持代码的模块化和可维护性,我们采用以下目录结构:

performance-monitor/├── dist/                 # 打包产物├── src/                  # 源码目录│   ├── index.ts          # 入口文件│   ├── loading/          # 加载与绘制采集(FP/FCP/LCP/Load)│   ├── interaction/      # 交互采集(FID/INP/LongTask)│   ├── visualStability/  # 视觉稳定性(CLS)│   ├── network/          # 资源与请求(ResourceTiming / API 请求)│   ├── report/           # 数据上报(sendBeacon / fetch keepalive)│   └── util/             # 工具与路由监听(getSelector/onUrlChange)├── test/                 # 测试靶场│   ├── server.js         # 本地测试服务│   ├── index.html        # 指标触发页面│   └── case-*.js         # 专项示例(cls/interaction/longtask/network)├── package.json          # 项目配置├── rollup.config.js      # Rollup 打包配置└── tsconfig.json         # TypeScript 配置
  • 「src 目录就干一件事」:把性能抓全、抓准。按 Loading (加载)、Interaction (交互)、VisualStability (视觉稳定)、Network (网络) 四大块分工,互不干扰。

  • 「开箱即用」:打包用 rollup,产物进 dist;test 里有可交互页面和本地服务,拿来就能跑。

  • 「拒绝黑盒」:用TypeScript 编写,直接调用浏览器原生的 PerformanceObserver 和 ResourceTiming API,不绕弯子,每一毫秒都来自于真实数据。

🚀 「浏览项目的完整代码及示例可以点击这里https://github.com/Teernage/performance-monitor ,如果对您有帮助欢迎Star。」

「1.主入口 (index.ts)」

入口文件负责对外暴露初始化方法,串联各个模块。

  • 「职责明确」:init() 方法一键启动所有监控,按用户体验生命周期(加载 -> 交互 -> 网络)依次调用。

  • 「配置中心」:构造函数接收 options,实现配置合并(如开发模式开启日志)。

  • 「模块解耦」:不直接写监控逻辑,而是通过 import 引入 loading/interaction/network/visualStability 四大模块的 startXXX 函数,各司其职。

  • 「示例代码」:

// src/index.ts
import { startFP, startFCP, startLCP, startLoad } from './loading';import { startFID, startINP, startLongTask } from './interaction';import { startCLS } from './visualStability';import { startEntries, startRequest } from './network';
export default class PerformanceMonitor {  constructor(options = {}) {      options;      constructor(options: any = {}) {        this.options = { log: true, ...options };      }
  init() {    // 1. 页面加载与渲染 (Loading & Rendering)    startFP();    startFCP();    startLCP();    startLoad();
    // 2. 交互响应 (Interaction)    startFID();    startINP();    startLongTask();
    // 3. 视觉稳定性 (Visual Stability)    startCLS();
    // 4. 资源与网络 (Resource & Network)    startEntries();    startRequest();
    console.log('Performance Monitor Initialized');  }}

「2. Loading 监控 (loading/index.ts)」

这部分主要负责捕捉页面从白屏到内容出现的关键时刻。我们把这个过程拆解为三个关键动作:「变色 (FP)」 -> 「有内容 (FCP)」 -> 「主角登场 (LCP)」。

  1. 「FP (First Paint)」:屏幕变色了(不白屏了)。
  2. 「FCP (First Contentful Paint)」:看见字或图了(有内容了)。
  3. 「LCP (Largest Contentful Paint)」:主角(大图/正文)出来了。
  4. 「Load」:资源全加载完了。

「(1) FP & FCP:屏幕终于亮了」

「先讲道理:」

  • 「为什么要一起抓这两个指标是一起抓的?」 因为它俩在浏览器眼里都属于 paint(绘制)类型,都是“第一眼”的感觉。
  • 「怎么抓?」 用 PerformanceObserver 蹲守。
  • 「避坑指南(Buffered 标志):」 这是一个极其容易被忽略的参数!
    • SDK 初始化往往比页面渲染晚。
    • 如果你不开启 buffered: true,就像你 10 点才去蹲守 9 点的日出,永远蹲不到。
    • 开启后,浏览器会把「过去发生过」的指标打包补发给你。

「代码实战:」

// src/loading/FP.ts (FCP 逻辑完全一致,只需改 entry.name)export function startFP() {  const entryHandler = (list) => {    for (const entry of list.getEntries()) {      // 筛选 'first-paint'      if (entry.name === 'first-paint') {        observer.disconnect(); // FP 一辈子只发生一次,抓到就撤,省内存
        const json = entry.toJSON();        console.log('FP Captured:', json);
        // 上报数据结构        const reportData = {          ...json,          type: 'performance',          name: entry.name,          pageUrl: window.location.href,        };      }    }  };
  // 1. 创建观测者  const observer = new PerformanceObserver(entryHandler);
  // 2. 开始蹲守 'paint' 频道  // buffered: true 是关键,确保能拿到 SDK 初始化之前的记录  observer.observe({ type: 'paint', buffered: true });
  // 3. 返回清理函数  return () => observer.disconnect();}

「(2) LCP:主角登场」

  • 「它是动态变化的」:LCP 代表视口内最大内容的渲染时间。不同于 FP/FCP 一次定音,LCP 是「渐进式」的。随着图片加载或字体渲染,更大的内容可能随后出现,浏览器会不断更新 LCP 候选值。

  • 「什么时候“定格”?」 浏览器会在「用户首次交互」(点击、按键)或「页面隐藏」时,停止产生新的 LCP。因此,我们不能抓到一个就上报,而是要监听这些“停止信号”,取「最后一次」候选值作为最终结果。

  • 「光有时间不够」:老板问你“为什么慢”,你不能光给个时间。我们需要利用 element 属性计算出 CSS 选择器,精准定位是「哪张图」或「哪段文字」拖了后腿。

「代码实战:」

// src/loading/LCP.tsimport { getSelector } from '../util/index';
export function startLCP() {  let lcpEntry: PerformanceEntry | undefined;  let hasReported = false;
  const observer = new PerformanceObserver((list) => {    for (const entry of list.getEntries()) {      // 记录最新的 LCP 候选值      lcpEntry = entry;    }  });  observer.observe({ type: 'largest-contentful-paint', buffered: true });
  const report = () => {    if (hasReported || !lcpEntry) return;    hasReported = true;
    const json = (lcpEntry as any).toJSON();    const reportData = {      ...json,      lcpTime: lcpEntry.startTime,      elementSelector: getSelector((lcpEntry as any).element),      type: 'performance',      name: lcpEntry.name,      pageUrl: window.location.href,    };    console.log('LCP Final Report:', reportData);  };
  // 页面隐藏或用户首次交互时,上报最终 LCP  const onHidden = () => {    if (document.visibilityState === 'hidden') report();  };
  // 监听页面显示隐藏  document.addEventListener('visibilitychange', onHidden, { once: true });  window.addEventListener('pagehide', report, { once: true });  // 监听用户交互  ['click', 'keydown', 'pointerdown'].forEach((type) => {    window.addEventListener(type, report, { once: true, capture: true });  });
  return () => {    observer.disconnect();    document.removeEventListener('visibilitychange', onHidden);  };}

「(3) 加载完成」

「先讲道理:」

  • 「为什么不用 window.onload?」 现在的 SPA(单页应用)和浏览器的“往返缓存”(BFCache)机制,让 onload 变得不那么靠谱(有时候回退页面不会触发 onload)。
  • 「为什么用 pageshow?」 它能覆盖更多场景,无论你是新打开的,还是后退回来的,它都会触发。
  • 「为什么套一层 requestAnimationFrame?」pageshow 触发时,浏览器可能还在忙着处理最后的渲染。我们用 rAF 往后稍一稍,让主线程先喘口气,获取的时间更精准,也不影响页面交互。

「代码实战:」

// src/loading/load.tsexport function startLoad() {  const onPageShow = (event) => {    // 往后推一帧,避免抢占主线程    requestAnimationFrame(() => {      ['load'].forEach((type) => {        const reportData = {          type: 'performance',          subType: type,          pageUrl: window.location.href,          // 计算相对时间          startTime: performance.now() - event.timeStamp,        };        console.log('Load Captured:', reportData);      });    });  };
  window.addEventListener('pageshow', onPageShow, true);
  return () => {    window.removeEventListener('pageshow', onPageShow, true);  };}

「3. Interaction 监控」

交互性能直接决定了用户觉得你的页面“顺不顺手”。这里我们重点关注 「FID/INP (响应速度)」 和 「Long Task (主线程阻塞)」。

「(1) FID & INP:点击要灵敏」

「先认个脸:」

  • 「FID (First Input Delay)」:「首次输入延迟」。看的是“第一印象”。用户刚进页面,第一次点按钮或者链接时,浏览器是不是在发呆?延迟了多久才理你?

  • 「INP (Interaction to Next Paint)」:「交互到下一次绘制」。看的是“全程表现”。不管你是刚来还是快走,只要在页面上点的任何一下卡了,INP 都会记下来,最后取最慢的那几次算总账。

「三句话讲明白」

  • 「相亲 vs 过日子」:FID 就像相亲,只要第一眼(第一次交互)没问题,后面拉胯它也不管;INP 就像过日子,日久见人心,它会盯着你全程的每一次表现,哪怕你前面表现再好,最后一下卡了,分也高不了。
  • 「只管排队 vs 全程跟踪」:FID 只管“排队时间”(你点下去到浏览器开始处理的时间);INP 管得更宽,它包括“排队 + 处理 + 渲染”的全过程。所以 INP 更能代表用户的真实感受。
  • 「谁在堵路」:想象你去餐厅吃饭(点击),服务员(主线程)正忙着给隔壁桌上菜(执行 JS),没空理你。你等服务员转过身来理你的这段时间,就是 FID。如果服务员理你了,但做菜慢(处理逻辑复杂),上菜也慢(渲染慢),这整个过程太久,INP 就会炸。

「直接上代码:FID」

// src/interaction/FID.tsexport function startFID() {  const observer = new PerformanceObserver((list) => {    for (const entry of list.getEntries()) {      // 核心公式:处理开始时间 - 点击时间 = 延迟时间      const delay = entry.processingStart - entry.startTime;      console.log('FID:', delay, entry.target);      observer.disconnect(); // FID 只看第一下,拿到就撤    }  });  observer.observe({ type: 'first-input', buffered: true });}

「代码怎么理解?」

  • processingStart - startTime:startTime 是你手指按下的瞬间,processingStart 是代码终于开始跑的瞬间。这中间的差值,就是浏览器因为“忙不过来”而让用户等待的时间。
  • disconnect():FID 全称是 「First」 Input Delay,既然是 First,抓到一次就可以收工了,省点内存。
  • buffered: true:防止 SDK 加载晚了。万一用户手快,脚本还没加载完就点了,这个参数能把那次点击记录补发给你。

「直接上代码 INP:」

// src/interaction/INP.tsexport function startINP() {  const observer = new PerformanceObserver((list) => {    for (const entry of list.getEntries()) {      // 不断收集,因为我们要找最慢的那个      // 这里只是简单的打印,实际开发中需要一个算法来取 top 几位      console.log('Interaction Latency:', entry.duration, entry.target);    }  });  // 注意:INP 监听的是 'event' 类型,不是 'first-input'  observer.observe({ type: 'event', durationThreshold: 16, buffered: true });}

「代码怎么理解?」

  • 「INP 代码」:type: 'event'。这里我们不断监听,不断打 log。实际场景里,你需要维护一个数组,把耗时最长的几次交互存下来,最后上报那个最慢的。
  • 「durationThreshold: 16」:这是个优化参数。意思是“小于 16ms(一帧)的交互我就不看了”,省得数据太多刷屏。

「踩坑提醒」

  • 「及格线在哪?(Google 标准)」

    -「FID (第一印象)」:「100ms」 以内都是优秀,超过 300ms 用户就会觉得“破网站怎么点不动”。

    -「INP (全程体验)」:「200ms」 以内算优秀,超过 「500ms」 用户就想砸键盘了。
  • 「为啥 FID 和 INP 的标准不一样?」

    - 因为 「FID 只算排队时间」(你还在门口等服务员),而 「INP 算的是全套服务时间」(排队 + 吃饭 + 买单)。INP 包含的阶段更多(处理 + 渲染),所以 Google 给的宽容度自然也更高。

  • 「INP 才是未来」:Google 已经在 2024 年正式用 INP 取代了 FID。想监控 INP?把 type 改成 event,然后别断开 (disconnect),一直记到页面走人就行。

  • 「谁在堵路」:通常是因为主线程在忙着执行巨大的 JS 脚本(Long Task),导致没空搭理用户的点击。

「(2) Long Task:主线程别堵车」

「先认个脸:」

  • 「Long Task (长任务)」:只要执行时间超过 「50 毫秒」 的任务,都叫长任务。
  • 「危害」:浏览器的主线程是“单线程”的,一次只能干一件事。如果一个任务霸占了主线程太久,其他的点击、滚动、渲染就都得排队,用户就会觉得“卡死”了。

「三句话讲明白」

  • 「独木桥效应」:主线程就像一座独木桥。平时过的小车(短任务)很快,大家都有路走。突然来了一辆大卡车(长任务),把桥堵得死死的,后面的车(用户交互)全被堵住了。
  • 「50ms 分界线」:为啥是 50ms?
    • 「100ms 法则」:心理学上,用户点击后 「100ms」 内有反应就算“即时”。
    • 「对半分」:Google 把这 100ms 切成两半:「50ms 给你跑代码」,「50ms 留给浏览器画画」。这样加起来刚好 100ms。
    • 「高刷屏怎么办」:虽然 120Hz 屏幕每帧只有 8ms,但在 Web 标准里,50ms 依然是那个平衡了“体验”和“代码复杂度”的安全及格线。
  • 「抓元凶」:监控 Long Task 不光是为了知道“卡了”,更是为了知道“谁卡了”。
    ❝

    「⚠️ 注意」:Long Task API 只能帮你定位到「宏观的容器」(比如是主页面卡了,还是广告 iframe 卡了),通常「无法」直接给出具体的 .js 文件路径或函数堆栈。想抓具体函数,得靠 Chrome DevTools。

    ❞

「直接上代码:」

// src/interaction/longTask.tsexport function startLongTask() {  const observer = new PerformanceObserver((list) => {    for (const entry of list.getEntries()) {      if (entry.duration > 50) {        // 抓到个慢的!看看是谁        // attribution里面藏着“罪魁祸首”的名字,通常包含:name(self/iframe), containerType(iframe/embed), containerSrc      // 虽不能精确到具体函数,但能帮你区分是“自己写的代码慢”还是“第三方插件慢”     // 如果类型是 'window',说明是当前主页面(你的代码)卡了;如果是 'iframe',那就是广告或插件卡了        console.log('LongTask:', entry.duration, entry.attribution);      }    }  });  // 开启监听,buffered 同样重要  observer.observe({ type: 'longtask', buffered: true });}

「代码怎么理解?」

  • 「entry.duration (耗时)」:虽然 API 标准定义长任务是 >50ms,但在实际工程中,50ms 的卡顿可能非常频繁(尤其在移动端)。为了减少日志噪音,我们通常会设置一个「更高的上报阈值」(比如 100ms 或 200ms),只关注那些真正让用户感到“卡顿”的严重问题。

  • 「entry.attribution (归因)」:这是帮你「快速定位“肇事者”范围」的关键字段。

    • 它不会告诉你具体是哪个函数慢(那是 DevTools 的事)。
    • 但它会告诉你任务来自哪里:是当前 「window」(你写的代码),还是某个 「iframe」(广告或第三方插件)。这对于理清责任(甩锅)非常有用。

「踩坑提醒」

  • 「拆分任务」:把一次性的大计算拆成多个小段,并在每段之间主动“让路”。常用手段:setTimeout(0) 切片、requestAnimationFrame 在绘制后继续、requestIdleCallback 在空闲期执行、重度计算迁移到 Web Worker。注意:requestIdleCallback 在后台标签页会被强烈限速,不适合关键路径。
  • 「广告背锅」:很多时候你会发现 Long Task 都是广告脚本(iframe)带来的。这种时候……你可以甩锅给广告商,或者延迟加载广告。

  • 「兼容性」:这个 API 兼容性还不错,但还是老规矩,Safari 可能比较高冷(较新版本才支持)。

「4. Visual Stability (视觉稳定性) 监控」

这可能是最让人抓狂的体验。

「CLS:页面别乱动」

  • 「别冤枉好人」:用户点了个按钮展开菜单,布局肯定会变,这叫“符合预期”

  • 「聚沙成塔」:CLS 不是一次性的,它是“积分制”。用户在页面上待多久,这期间所有的小抖动都要加起来,算总账。

  • 「秋后算账」:千万别抖一下报一下!CLS 是“长跑比赛”,不到终点(页面关闭/隐藏)不知道最终成绩。必须等用户关页面或者切后台的时候,把最后的总分一次性报上去。

// src/interaction/CLS.tsexport function startCLS() {  let clsValue = 0;  const observer = new PerformanceObserver((list) => {    for (const entry of list.getEntries()) {      // 核心:剔除用户交互(点击/输入)导致的预期偏移      if (!entry.hadRecentInput) {        clsValue += entry.value;      }    }  });  observer.observe({ type: 'layout-shift', buffered: true });
  const report = () => console.log('CLS Final:', clsValue);
  // 双重保险:兼容各类浏览器的卸载场景  window.addEventListener('pagehide', report, { once: true });  document.addEventListener(    'visibilitychange',    () => {      if (document.visibilityState === 'hidden') report();    },    { once: true }  );}

「代码怎么理解?」

  • 「怎么区分是“好动”还是“乱动”?」

    刚刚我们说用户交互(比如点按钮展开菜单)导致的布局变化不能算 CLS。那怎么判断呢? 浏览器提供了 hadRecentInput 字段。只要用户最近 「500ms」 内有过点击或按键,浏览器就会把这个字段标为 true。咱们代码里必须把这些“良民”过滤掉,只抓那些“莫名其妙”的抖动。

  • 「分数怎么算?」

    entry.value 就是每一次抖动的分数。比如一张大图突然插进来,把文字挤下去 100px,可能就贡献了 0.05 分。我们要做的就是一个无情的“加法器”,把这些分数全加起来。

  • 「为啥要监听两个卸载事件?」

    visibilitychange 和 pagehide 都是用来监听页面关闭/隐藏的。为啥要搞两个?因为浏览器脾气不一样:有的喜欢 pagehide(比如 Safari),有的推荐 visibilitychange。为了保证数据不丢,咱们搞个“双保险”,谁先触发就算谁的。

    • 「为啥不用 beforeunload?」早年间确实流行用 beforeunload 或 unload,但现在它们「不靠谱」了,尤其是在手机上。用户直接划掉 App、切后台,这些事件经常「不会触发」。而且它还会阻止浏览器做“往返缓存”(BFCache),拖慢页面后退速度。所以现在的标准姿势就是 visibilitychange + pagehide。

「踩坑提醒」

  • 成绩线:CLS < 0.1 很好,> 0.25 需要重点优化
  • 动态内容要“留坑位”:骨架屏/固定尺寸,能明显降低位移
  • 广告/懒加载图片经常是元凶,优先排查
❝

「💡 进阶小贴士:」这里为了演示原理,我们使用了简单的累加法。但在生产环境中(特别是 SPA 单页应用),Google 推荐使用 「Session Window(会话窗口)」 算法

❞

「5. Network 监控:查查谁在拖后腿」

「先认个脸:」

  • 「Resource Timing (资源计时)」:专门管资源加载的。不管是图片、CSS、JS 文件,还是接口请求 (XHR/Fetch),只要是从网络下载的东西,它都能记一笔。
  • 「核心指标」:除了总耗时 (duration),还能细到 DNS 解析多久、TCP 建连多久、首字节时间 (TTFB) 等等。

「三句话讲明白」

  • 「查快递」:你买东西(请求资源),想知道为什么这么慢?是卖家发货慢(TTFB),还是路上堵车(下载慢)?Resource Timing 就是那个详细的物流单。
  • 「不只是图片」:别被名字骗了,它不光管图片 CSS,你的 fetch 请求、axios 请求,只要走了网络,它都能监控到。
  • 「严防死守」:浏览器为了安全,对于跨域的资源(比如你用了百度的图片),默认只告诉你“用了多久”,不告诉你“怎么用的”(DNS/TCP 细节),除非对方给了通行证。

「直接上代码:」

export function startEntries() {  const observer = new PerformanceObserver((list) => {    for (const entry of list.getEntries()) {      // resource 类型包含了 img, script, css, fetch, xmlhttprequest, link 等      if (entry.entryType === 'resource') {        console.log(          'Resource:',          entry.name,          entry.initiatorType,          entry.duration        );      }    }  });  // 同样记得 buffered: true,防止漏掉页面刚开始加载的那些资源  observer.observe({ type: 'resource', buffered: true });}

「代码怎么理解?」

  • entryType === 'resource':这个频道包罗万象。图片 (img)、样式 (css)、脚本 (script) 甚至你的接口调用 (fetch/xmlhttprequest) 都在这儿。
  • initiatorType:这个字段告诉你资源是谁发起的。是 <img src="..."> 发起的?还是 fetch() 发起的?一看便知。

「进阶用法:监控接口 (API) 耗时详情」

有时候我们不关心图片资源,而是重点关注「后端接口的响应耗时」。通过过滤 fetch 和 xmlhttprequest,我们不仅能知道接口慢不慢,还能知道「慢在哪里」(是 DNS、TCP 还是服务端处理)。

export function startRequest() {  const entryHandler = (list) => {    const data = list.getEntries();    for (const entry of data) {      // 过滤出 API 请求 (Fetch 和 XHR)      if (        entry.initiatorType === 'fetch' ||        entry.initiatorType === 'xmlhttprequest'      ) {        const reportData = {          name: entry.name, // 请求地址          type: 'performance',          subType: entry.entryType,          sourceType: entry.initiatorType,          duration: entry.duration, // 请求总耗时          dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 解析耗时          tcp: entry.connectEnd - entry.connectStart, // TCP 连接耗时          ttfb: entry.responseStart - entry.requestStart, // 首字节响应时间 (服务端处理时间)          transferSize: entry.transferSize, // 传输字节数          startTime: entry.startTime, // 请求开始时间          pageUrl: window.location.href,        };        console.log('Network Request:', reportData);      }    }  };
  // 这里不调用 disconnect(),以便持续监听后续产生的网络请求  const observer = new PerformanceObserver(entryHandler);  observer.observe({ type: 'resource', buffered: true });}

「代码怎么理解?」

  • 「TTFB (首字节时间) —— “厨师做菜慢”」

    • 公式:responseStart - requestStart。
    • 「大白话」:你点完菜(发送请求)到服务员端上第一盘菜(收到第一个字节)的时间。
    • 「谁背锅」:这时间长,说明「后端处理慢」(查数据库慢、业务逻辑太复杂)。跟网速没啥关系,纯粹是“后厨”忙不过来。赶紧截图甩给后端开发!
  • 「TCP & DNS —— “找路和打招呼”」

    • 「DNS」:就像查电话本。要把 api.example.com 变成 IP 地址。如果这块慢,说明用户离你的服务器太远,该上 CDN 了。
    • 「TCP」:就像见面握手。客户端和服务器得先“握手”建立连接才能说话。如果是 HTTPS,还得再加一轮 SSL 握手(查身份证)。这一套下来也得耗不少时间。
  • 「TransferSize (传输大小) —— “运货量”」

    • 「大白话」:接口到底吐了多少数据给你?
    • 「场景」:有时候接口慢,不是因为后端慢,而是因为「数据量太大」。比如一个列表接口,一下子返回了 10000 条数据,足足 5MB。光是下载这 5MB 就得好几秒。这时候就得让后端搞“分页”了。

「踩坑提醒」

  • 「跨域拿不到细节」:这是最常见的坑。看到 DNS 时间是 0 别奇怪,八成是跨域了且没加 Timing-Allow-Origin 头。这是浏览器为了保护隐私。
  • 「别全报」:一个页面可能有上百个资源,全报上去服务器受不了。建议设个门槛(比如只报 > 1 秒的),或者只报核心的 JS/CSS。
  • 「接口监控」:很多人不知道 fetch 请求也能在这里抓到。其实用它来监控后端接口性能,比自己封装 axios 拦截器要准得多,因为它算的是浏览器底层的真实时间。

「6. 必备工具函数:定位神器 (util/index.js)」

最后,我们得有个工具能帮我们“指路”。只告诉老板“图片慢”没用,你得告诉他是“哪个图片慢”。

getElementSelector 就是这个“定位神器”。它能把一个 DOM 元素转换成 CSS 选择器(比如 body > div#app > h1),让你直接在代码里找到它。

// src/util/index.jsexport function getElementSelector(element) {  if (!element || element.nodeType !== 1) return '';
  // 如果有 id,直接返回 #id  if (element.id) {    return `#${element.id}`;  }
  // 递归向上查找  let path = [];  while (element) {    let name = element.localName;    if (!name) break;
    // 如果有 id,拼接后停止    if (element.id) {      path.unshift(`#${element.id}`);      break;    }
    // 加上 class    let className = element.getAttribute('class');    if (className) {      name += '.' + className.split(/\s+/).join('.');    }
    path.unshift(name);    element = element.parentElement;  }
  return path.join(' > ');}

7. 数据上报(report/index.ts)

收集到数据后,如何发给后端?这看似简单,实则暗藏玄机。

1. 核心痛点:页面关了,请求还没发完怎么办?

用户看完网页直接关掉(或者刷新跳转),这时候浏览器会无情地杀掉当前页面进程里所有正在跑的异步请求(XHR/Fetch)。

结果就是:「监控数据还没发出去,就死在半路上了。」

2. 解决方案

为了确保数据必达,我们采用一套组合拳:

  1. 「首选」「Navigator.sendBeacon」:

    它是专门为“页面卸载上报”设计的。

    「特点」:浏览器会在后台默默把数据发完,不阻塞页面关闭,也不会被杀掉。

  2. 「次选」「fetch」「+」「keepalive」:

    如果浏览器不支持 Beacon,或者你需要自定义 Header(Beacon 不支持自定义 Header),就用 「fetch」 并开启 「keepalive: true」。

    「特点」:告诉浏览器“这个请求很重要,页面关了也请帮我发完”。

3. 代码实现

export const sendBehaviorData = (data: Record<string, any>, url: string) => {  // 1. 包装数据:加上一些公共信息(比如 UserAgent,屏幕分辨率等)  const dataToSend = {    ...data,    userAgent: navigator.userAgent,    // screenWidth: window.screen.width, // 可选  };
  // 2. 优先使用 sendBeacon (最稳,且不阻塞)  // 注意:sendBeacon 不支持自定义 Content-Type,默认是 text/plain  // 这里用 Blob 强制指定为 application/json  if (navigator.sendBeacon) {    const blob = new Blob([JSON.stringify(dataToSend)], {      type: 'application/json',    });
    // sendBeacon 返回 true 表示进入队列成功    navigator.sendBeacon(url, blob);
    return;  }
  // 3. 降级方案:使用 fetch + keepalive  // 即使页面关闭,keepalive 也能保证请求发出  fetch(url, {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify(dataToSend),    keepalive: true, // <--- 关键参数!防止页面关闭时请求被杀  }).catch((err) => {    console.error('上报失败:', err);  });};

3. 工程化构建配置

既然是 SDK,最好的分发方式当然是发布到 NPM。这样其他项目只需要一行命令就能接入你的前端错误监控系统。

这里我们选择 「Rollup」对代码进行打包,因为它比 Webpack 更适合打包库(Library),生成的代码更简洁。

3.1 package 配置 (package.json)

package.json 不仅仅是依赖管理,它还定义了你的包如何被外部使用。配置不当会导致用户引入报错或无法获得代码提示。

{  "name": "performance-sdk",  "version": "1.0.0",  "description": "A lightweight performance monitoring SDK",  "main": "dist/index.cjs.js",  "module": "dist/index.esm.js",  "browser": "dist/index.umd.js",  "type": "module",  "scripts": {    "build": "rollup -c",    "dev": "rollup -c -w"  },  "keywords": ["performance", "monitor", "sdk"],  "license": "MIT",  "files": ["dist"],  "devDependencies": {    "rollup": "^4.9.0",    "@rollup/plugin-typescript": "^11.1.0",    "@rollup/plugin-terser": "^0.4.0",    "typescript": "^5.3.0",    "tslib": "^2.6.0"  }}

「💡 关键字段解读:」

  • 「name」: 包的“身份证号”。在 NPM 全球范围内必须唯一,发布前记得先去搜一下有没有重名。
  • 「入口文件“三剑客”」(决定了别人怎么引用你的包):
    • 「main」: 「CommonJS 入口」。给 Node.js 环境或老旧构建工具(如 Webpack 4)使用的。
    • 「module」: 「ESM 入口」。给现代构建工具(Vite, Webpack 5)使用的。支持 Tree Shaking(摇树优化),能减小体积。
    • 「browser」: 「UMD 入口」。给浏览器直接通过 <script> 标签引入使用的(如 CDN)。
  • 「files」: 「发布白名单」。指定 npm publish 时只上传哪些文件(这里我们只传编译后的 dist 目录)。源码、测试代码等不需要发上去,以减小包体积。

3.2 TypeScript 配置 (tsconfig.json)

我们需要配置 TypeScript 如何编译代码,并生成类型声明文件(.d.ts),这对使用 TS 的用户非常友好。

{  "compilerOptions": {    "target": "es5", // 编译成 ES5,兼容旧浏览器    "module": "esnext", // 保留 ES 模块语法,交给 Rollup 处理    "declaration": true, // 生成 .d.ts 类型文件 (关键!)    "declarationDir": "./dist", // 类型文件输出目录    "strict": true, // 开启严格模式,代码更健壮    "moduleResolution": "node" // 按 Node 方式解析模块  },  "include": ["src/**/*"] // 编译 src 下的所有文件}

3.3 Rollup 打包配置 (rollup.config.js)

为了兼容各种使用场景,我们配置 Rollup 输出三种格式:

  1. 「ESM (.esm.js)」: 给现代构建工具(Vite, Webpack)使用,支持 Tree Shaking。
  2. 「CJS (.cjs.js)」: 给 Node.js 或旧版工具使用。
  3. 「UMD (.umd.js)」: 可以直接在浏览器通过 <script> 标签引入,会挂载全局变量。
import typescript from '@rollup/plugin-typescript';import terser from '@rollup/plugin-terser';
export default {  input: 'src/index.ts',  output: [    { file: 'dist/index.cjs.js', format: 'cjs', sourcemap: true },    { file: 'dist/index.esm.js', format: 'es', sourcemap: true },    {      file: 'dist/index.umd.js',      format: 'umd',      name: 'PerformanceSDK',      sourcemap: true,      plugins: [terser()],    },  ],  plugins: [    typescript({      tsconfig: './tsconfig.json',      declaration: true,      declarationDir: 'dist',    }),  ],};

4. 发布到 NPM (保姆级教程)

4.1 准备工作

  1. 「注册账号」:去 npmjs.com 注册一个账号(记得验证邮箱,否则无法发布)。
  2. 「检查包名」:在 NPM 搜一下你的 package.json 里的 name,确保没有被占用。如果不幸重名,改个独特的名字,比如 performance-sdk-vip。

4.2 终端操作三步走

打开终端(Terminal),在项目根目录下操作:

「第一步:登录 NPM」

npm login
  • 输入命令后按回车,浏览器会弹出登录页面。
  • 或者在终端根据提示输入用户名、密码和邮箱验证码。
  • 登录成功后会显示 Logged in as <your-username>.
  • 注意:如果你之前切换过淘宝源,发布时必须切回官方源:npm config set registry https://registry.npmjs.org/

「第二步:打包代码」

确保 dist 目录是最新的,不要发布空代码。

npm run build

「第三步:正式发布」

npm publish --access public
  • --access public 参数用于确保发布的包是公开的(特别是当包名带 @ 前缀时)。
  • 看到 + [email protected] 字样,恭喜你,发布成功!

现在,全世界的开发者都可以通过 npm install performance-sdk-vip 来使用你的作品了!

5. 如何使用

SDK 发布后,支持多种引入方式,适配各种开发场景。

  • NPM + ES Modules(推荐)
npm install performance-sdk
import PerformanceMonitor from 'performance-sdk';
const monitor = new PerformanceMonitor({  /* 可选:log, sampleRate, reportUrl */});monitor.init();
  • CDN 直接引入(UMD)
<scriptsrc="https://unpkg.com/[email protected]/dist/index.umd.js"></script><script>  const monitor = new PerformanceSDK.PerformanceMonitor({    /* 可选配置 */  });  monitor.init();</script>

6. 总结与展望

恭喜你!到这里,你已经亲手打造了一套「麻雀虽小,五脏俱全」的性能监控 SDK。

咱们再回头看看这四大支柱:

  1. 「Loading (加载)」:FP/FCP/LCP 负责盯着“快不快”。白屏时间短,用户才愿意留下来。
  2. 「Interaction (交互)」:FID/INP 负责盯着“顺不顺”。点击有反馈,用户才觉得好用。
  3. 「Visual Stability (稳定性)」:CLS 负责盯着“稳不稳”。页面不乱跳,用户才不心烦。
  4. 「Network (网络)」:Resource Timing 负责盯着“通不通”。接口响应快,体验才有底气。

「下一步可以玩点啥?」

性能监控只是前端监控体系的「三分之一」。如果你想打造一个无死角的监控系统,光看性能是不够的:

  • 「报错了咋办?」 JS 挂了、接口 500 了、资源加载失败了……这些需要「错误监控」来兜底。
  • 「用户在干啥?」 用户点了哪个按钮?在哪个页面停留最久?这些需要「行为监控」来分析。

当然,你还可以结合:

  • 「可视化大屏」:光有数据不行,得画成图表(ECharts/Grafana)。看着曲线波动,才有成就感。
  • 「报警机器人」:LCP 超过 4 秒了?接口报错率飙升了?直接钉钉/飞书群里 @ 全体成员,把问题扼杀在摇篮里。

性能优化是一场「没有终点的马拉松」。希望这篇文章能是你打造专属监控系统的起点。「Happy Coding!」

图片
点击"阅读原文"了解详情~