跳到主要内容
预计阅读 50 分钟

可观测性篇 | 性能监控

前言

上一章我们聊了错误监控——知道页面”有没有出问题”。这一章我们来聊可观测性的另一个核心维度:性能监控——知道页面”快不快”。

作为前端开发者,我们常常花大量时间在”功能实现”上——页面渲染正确、交互逻辑正确、接口对接正确。但很多时候我们忽略了一个同样重要的问题:用户体验到底好不好?

你写的页面在你的 MacBook Pro 上跑得飞快,不代表在用户的千元安卓机上也一样流畅。你本地网络是 200Mbps 光纤,不代表用户在地铁里的 4G 信号也能秒开页面。

这就是性能监控的意义——用数据说话,而不是用感觉。

在面试中,性能监控是一个”区分度极高”的话题。能聊到 FCP、LCP 这些指标说明你知道”什么是好的性能”,能聊到 PerformanceObserver 说明你知道”怎么采集数据”,能聊到 sendBeacon 和采样策略说明你知道”怎么在生产环境落地”。这三个层次对应了初级、中级、高级前端的认知差异。

本章,我们就从性能指标是什么、怎么采集、怎么上报、怎么分析这四个维度,把前端性能监控的知识体系讲清楚。


诊断自测

在进入正文之前,先用几道题测测你目前对性能监控的理解。

Q1:FCP 和 LCP 分别衡量的是什么?为什么光看 FCP 不够,还需要 LCP?

点击查看答案

FCP(First Contentful Paint)衡量的是页面第一个有内容的像素渲染到屏幕上的时间,可能只是一个导航栏甚至一个 loading spinner。LCP(Largest Contentful Paint)衡量的是视口内最大内容元素(通常是主图或主标题)渲染完成的时间。FCP 只能告诉你”页面开始有东西了”,但用户真正关心的是”我想看的内容出来了没有”——这正是 LCP 的意义。一个页面 FCP 很快(loading 动画出现了)但 LCP 很慢(真正的内容迟迟不出现),用户体验依然很差。

Q2:performance.timingPerformanceObserver 有什么区别?为什么现代监控方案更推荐后者?

点击查看答案

performance.timing 是旧版 Navigation Timing API,返回一个包含各时间点的对象,所有数据一次性获取。它的问题是:(1)只能获取页面导航阶段的数据,无法监测后续的动态内容;(2)时间点是绝对时间戳,计算不方便;(3)已被标记为 deprecated。PerformanceObserver 是新版 API,采用观察者模式,可以异步监听各类性能条目(paint、largest-contentful-paint、longtask 等),不会阻塞主线程,也能捕获页面加载后动态产生的性能数据。现代监控方案都基于 PerformanceObserver,因为它更灵活、更准确、对性能本身的影响更小。

Q3:为什么用 navigator.sendBeacon() 上报性能数据,而不是普通的 fetchXMLHttpRequest

点击查看答案

sendBeacon 专门为”页面卸载时发送数据”这个场景设计。它有几个关键优势:(1)请求在浏览器后台异步发送,不会阻塞页面卸载或下一个页面的加载;(2)即使页面已经关闭,浏览器也会保证把数据发出去,而 fetch 或 XHR 在 unload/beforeunload 中发送时可能被浏览器取消;(3)它是 POST 请求,可以携带数据。这让它成为上报”页面离开前最后一批数据”的最佳选择。


一、性能指标:用户体验的”度量衡”

要做性能监控,首先得知道”好性能”长什么样。Google 提出了一套以用户为中心的性能指标体系,也就是我们常说的 Web Vitals

1.1 TTFB(Time to First Byte)

定义: 从用户发起导航请求,到浏览器收到响应的第一个字节所花的时间。

用户点击链接 ──> DNS 查询 ──> TCP 连接 ──> TLS 握手 ──> 服务器处理 ──> 第一个字节到达
|_________________________ TTFB __________________________|

TTFB 反映的是服务端响应速度 + 网络链路耗时。它不是直接的用户感知指标,但会影响后续所有指标——如果 TTFB 就很慢,FCP、LCP 必然也快不了。

参考值: 好的 TTFB 应该在 800ms 以内

1.2 FCP(First Contentful Paint)

定义: 页面第一个文本、图片、SVG 或非白色 Canvas 渲染到屏幕上的时间。

FCP 回答的问题是:“页面是不是还是白屏?” 用户看到任何内容——哪怕只是一个 loading 文字——FCP 就算完成了。

参考值: 好的 FCP 应该在 1.8 秒以内

1.3 LCP(Largest Contentful Paint)

定义: 视口内最大的内容元素完成渲染的时间。“最大内容元素”通常是 <img><video> 的海报帧、带背景图的块级元素、或大段文本块。

LCP 回答的问题是:“页面的主要内容加载好了吗?” 这是用户感知的”页面加载完成”的最关键指标。

参考值: 好的 LCP 应该在 2.5 秒以内

需要注意的细节:

  • LCP 会随着页面渲染不断更新——比如先渲染了标题(LCP = 标题),然后主图加载完成(LCP 更新为主图)
  • 一旦用户产生交互(点击、滚动、键盘输入),LCP 就停止更新
  • LCP 不考虑视口外的元素

1.4 FID / INP(交互响应性)

FID(First Input Delay): 用户第一次与页面交互(点击、触摸、按键)时,从事件触发到浏览器实际开始处理事件之间的延迟。

FID 衡量的是”页面看起来能点,但实际点了以后多久才有反应”。如果主线程正在执行一段长任务(比如解析大量 JS),用户的点击事件就得排队等着,这段等待时间就是 FID。

INP(Interaction to Next Paint): 从 2024 年 3 月起,Google 用 INP 替代了 FID 作为核心 Web Vital。INP 不只看”第一次”交互,而是综合衡量整个页面生命周期中所有交互的响应延迟,取其中较差的值作为最终得分。

为什么 INP 取代了 FID? 因为 FID 只看第一次交互,可能恰好那时主线程很闲,FID 很好看。但之后用户操作时主线程可能正在忙,体验很差。INP 更能反映真实的全程交互体验

参考值: 好的 INP 应该在 200ms 以内

1.5 CLS(Cumulative Layout Shift)

定义: 页面生命周期中所有意外布局偏移的累积得分。

什么是”意外布局偏移”?就是你正在读文章,突然上面插进来一张图,把你正在读的文字推下去了;或者你准备点一个按钮,突然上面加载完了一个广告,按钮位置变了,你点到了广告——这种体验极其糟糕。

参考值: 好的 CLS 应该在 0.1 以内

常见导致 CLS 的原因:

  • 图片/视频没有设置宽高(加载完成后撑开容器导致偏移)
  • 动态插入 DOM 内容(广告、弹窗、toast)
  • Web 字体加载导致的文字重排(FOIT/FOUT)
  • 动态加载的组件没有预留空间

指标总结表

指标衡量什么好的阈值用户感知
TTFB服务端响应速度< 800ms”点了之后有没有反应”
FCP首次内容渲染< 1.8s”是不是白屏”
LCP最大内容渲染< 2.5s”主内容出来了没”
INP交互响应延迟< 200ms”点了之后卡不卡”
CLS布局稳定性< 0.1”页面有没有乱跳”

二、性能数据采集:Performance API 全解

知道了要关注哪些指标,下一步是”怎么在代码中采集这些数据”。

2.1 旧版 API:performance.timing(了解即可)

// ⚠️ 已废弃,但面试可能会问
const timing = performance.timing;

const dns = timing.domainLookupEnd - timing.domainLookupStart;
const tcp = timing.connectEnd - timing.connectStart;
const ttfb = timing.responseStart - timing.navigationStart;
const domReady = timing.domContentLoadedEventEnd - timing.navigationStart;
const load = timing.loadEventEnd - timing.navigationStart;

这个 API 简单直接,但有明显的局限:

  1. 只能获取导航阶段的数据,无法监测后续的 LCP、CLS 等
  2. 返回的是绝对时间戳(Date.now() 精度),不够精确
  3. 同步获取,可能在数据还没 ready 的时候就去读了
  4. 已被标记为 deprecated,新版浏览器未来可能移除

现在的替代方案是 Navigation Timing Level 2,通过 performance.getEntriesByType('navigation') 获取,返回的是高精度的相对时间(DOMHighResTimeStamp)。

2.2 核心 API:PerformanceObserver

PerformanceObserver 是现代性能监控的基石。它基于观察者模式,可以异步监听浏览器产生的各类性能条目。

基本用法:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.name, entry.startTime, entry.duration);
  }
});

// 监听 LCP
observer.observe({ type: 'largest-contentful-paint', buffered: true });

这里的 buffered: true 非常关键——它的意思是”把在我开始监听之前就已经产生的条目也给我”。如果不加这个参数,你在页面加载完才注册的 Observer 就会错过 FCP、LCP 等早期指标。

采集 FCP:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      console.log('FCP:', entry.startTime);
    }
  }
}).observe({ type: 'paint', buffered: true });

采集 LCP:

new PerformanceObserver((list) => {
  const entries = list.getEntries();
  // LCP 会多次触发,取最后一个
  const lastEntry = entries[entries.length - 1];
  console.log('LCP:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

采集 CLS:

let clsValue = 0;

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 只计算非用户输入触发的布局偏移
    if (!entry.hadRecentInput) {
      clsValue += entry.value;
    }
  }
  console.log('Current CLS:', clsValue);
}).observe({ type: 'layout-shift', buffered: true });

注意 CLS 的计算有一个细节:hadRecentInputtrue 表示这次偏移是由用户交互(如点击展开面板)引起的,属于”预期内的偏移”,不应该计入 CLS。

2.3 web-vitals 库:Google 的官方方案

手动用 PerformanceObserver 采集各个指标,需要处理很多边界情况(比如 LCP 在页面切到后台时应该停止、CLS 需要按”会话窗口”分组计算等)。Google 提供了一个官方的 web-vitals 库,帮你把这些细节都处理好了。

import { onCLS, onFID, onLCP, onFCP, onTTFB, onINP } from 'web-vitals';

onFCP(metric => {
  console.log('FCP:', metric.value);
  // metric.id     - 唯一标识
  // metric.name   - 'FCP'
  // metric.value  - 具体数值(毫秒)
  // metric.rating - 'good' | 'needs-improvement' | 'poor'
  // metric.delta  - 自上次报告以来的变化量
});

onLCP(metric => {
  console.log('LCP:', metric.value, metric.rating);
});

onCLS(metric => {
  console.log('CLS:', metric.value);
});

onINP(metric => {
  console.log('INP:', metric.value);
});

onTTFB(metric => {
  console.log('TTFB:', metric.value);
});

为什么推荐用 web-vitals?

  1. 边界情况处理完善:比如页面 visibility 变化、BFCache 恢复等
  2. 计算逻辑与 Chrome 一致:和 Lighthouse、CrUX 报告用的算法相同
  3. 体积极小:gzip 后约 1.5KB
  4. 提供 rating 字段:直接告诉你指标是 good、needs-improvement 还是 poor

三、Long Task 检测

Long Task(长任务) 指的是占用主线程超过 50ms 的任务。在长任务执行期间,浏览器无法响应用户交互、无法进行渲染更新——用户会感觉到”卡顿”。

为什么是 50ms?

这和人类的感知阈值有关。研究表明,操作后 100ms 以内给出反馈,用户会觉得”即时响应”。而浏览器在接收到用户输入后,还需要一些时间做事件处理和渲染,留给 JS 执行的时间大约就是 50ms。超过这个时间,用户就可能感知到延迟。

检测方式

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('Long Task detected:', {
      duration: entry.duration,
      startTime: entry.startTime,
      // 注意:出于安全原因,跨域脚本的详细信息会被隐藏
      attribution: entry.attribution
    });

    // 可以上报给监控系统
    if (entry.duration > 100) {
      reportLongTask(entry);
    }
  }
}).observe({ type: 'longtask', buffered: true });

Long Task 与 INP 的关系

Long Task 是导致 INP 差的主要原因。当用户点击按钮时,如果主线程正在执行一个 200ms 的长任务,那么这次交互的响应延迟至少就是 200ms,INP 就会很差。

优化思路:

  • 把长任务拆分成多个小任务(用 setTimeout(fn, 0)requestIdleCallback、或 scheduler.yield()
  • 把计算密集型任务移到 Web Worker
  • 减少不必要的 JS 执行(代码分割、Tree Shaking、延迟加载)
// 把一个长任务拆分为多个小任务
async function processLargeArray(items) {
  const CHUNK_SIZE = 100;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    processChunk(chunk);

    // 每处理完一批,让出主线程
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

四、性能数据上报策略

采集到性能数据后,需要把数据发送到后端进行分析。这一步看似简单,但在生产环境中有很多讲究。

4.1 上报方式对比

方式一:XMLHttpRequest / fetch

// ❌ 不推荐用于页面卸载场景
fetch('/api/metrics', {
  method: 'POST',
  body: JSON.stringify(metrics),
  headers: { 'Content-Type': 'application/json' }
});

问题:在 unloadbeforeunload 事件中发送的请求,浏览器可能会取消(因为页面已经在关闭了)。虽然可以用 keepalive: true 来缓解,但兼容性和行为不如 sendBeacon 稳定。

方式二:navigator.sendBeacon(推荐)

// ✅ 推荐
navigator.sendBeacon('/api/metrics', JSON.stringify(metrics));

sendBeacon 的优势前面已经讲过:后台异步发送、不阻塞页面卸载、浏览器保证发送完成。它是页面卸载时上报数据的最佳方案

sendBeacon 也有局限:

  • 只支持 POST 请求
  • 无法获取响应内容
  • 数据大小有限制(通常 64KB)
  • 无法设置自定义请求头(如果要带 Token,需要用其他方式)

方式三:Image Beacon(传统方案)

// 兼容性最好,但只能 GET,数据量有限
const img = new Image();
img.src = `/api/metrics?data=${encodeURIComponent(JSON.stringify(metrics))}`;

通过创建一个 1x1 的透明图片来发送请求,优点是兼容性极好(甚至兼容 IE6)、不受跨域限制。缺点是只能用 GET、URL 长度有限制。

4.2 上报时机

不要在指标产生的瞬间立即上报。 频繁的网络请求本身就会影响性能,而且页面生命周期中的指标会不断更新(比如 LCP 和 CLS)。

推荐的策略是:

class MetricsCollector {
  constructor() {
    this.buffer = [];
    this.setupFlush();
  }

  add(metric) {
    this.buffer.push({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      timestamp: Date.now(),
      url: location.href,
      // 附加上下文信息
      connection: navigator.connection?.effectiveType,
      deviceMemory: navigator.deviceMemory
    });
  }

  setupFlush() {
    // 策略 1:页面卸载时上报
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.flush();
      }
    });

    // 策略 2:定时批量上报(兜底)
    setInterval(() => {
      if (this.buffer.length > 0) {
        this.flush();
      }
    }, 30000);

    // 策略 3:缓冲区满时上报
    // 在 add() 方法中检查 buffer.length
  }

  flush() {
    if (this.buffer.length === 0) return;

    const data = JSON.stringify(this.buffer);
    this.buffer = [];

    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/metrics', data);
    } else {
      // fallback
      fetch('/api/metrics', {
        method: 'POST',
        body: data,
        keepalive: true
      });
    }
  }
}

为什么监听 visibilitychange 而不是 unload?因为在移动端,用户切走 App 后浏览器可能直接杀掉进程,unload 事件根本不会触发。而 visibilitychange 在页面从”可见”变为”隐藏”时一定会触发,覆盖面更广。

4.3 采样

在流量大的网站上,如果每个用户的每次访问都上报,数据量会非常恐怖。合理的做法是采样上报

// 10% 的用户上报详细性能数据
const SAMPLE_RATE = 0.1;
const shouldSample = Math.random() < SAMPLE_RATE;

if (shouldSample) {
  initPerformanceMonitoring();
}

采样需要注意:

  • 采样决策应该在页面加载初期做出,并且在整个页面生命周期中保持一致
  • 采样率根据流量调整:日活百万级用 1%~5%,日活万级用 50%~100%
  • 对于性能特别差的用户(比如 LCP > 10s),可以提高采样率,重点关注

4.4 空闲上报

对于不那么紧急的性能数据,可以利用浏览器的空闲时段来上报,避免影响用户的交互体验:

function reportWhenIdle(data) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      navigator.sendBeacon('/api/metrics', JSON.stringify(data));
    }, { timeout: 5000 }); // 最多等 5 秒
  } else {
    // fallback:延迟上报
    setTimeout(() => {
      navigator.sendBeacon('/api/metrics', JSON.stringify(data));
    }, 3000);
  }
}

五、性能分析工具

有了数据采集和上报,还需要工具来帮助分析和定位问题。

5.1 Lighthouse

Lighthouse 是 Chrome 内置的网页质量审计工具,可以通过 Chrome DevTools 的 Lighthouse 面板、命令行、或 Node.js API 运行。

它的工作原理:

  1. 打开一个 Chrome 实例(可以无头模式)
  2. 模拟特定的网络和 CPU 条件(默认模拟中等移动设备)
  3. 加载目标页面
  4. 采集性能指标
  5. 根据一套评分算法给出 0-100 的分数和优化建议

关键注意事项:

  • Lighthouse 在模拟环境中运行,分数和真实用户体验可能有差异
  • 每次运行结果会有波动(网络、CPU 负载等因素),建议跑多次取中位数
  • Lighthouse 的性能评分权重会随版本更新调整,不要把分数当作绝对指标
  • 它给出的优化建议非常有价值,比如”推迟加载屏幕外图片”、“移除未使用的 CSS”等

5.2 Chrome DevTools Performance 面板

Performance 面板是做精细性能调优的利器。它能录制一段时间内浏览器的所有活动,以火焰图(Flame Chart)的形式展示。

录制后能看到什么:

  • Main 线程活动:JS 执行、样式计算、布局、绘制
  • Long Tasks:超过 50ms 的任务会被红色标记
  • 帧率:每一帧的耗时,帮你发现掉帧
  • 网络请求:资源加载的时序和耗时
  • Web Vitals 标记:FP、FCP、LCP、Layout Shift 等关键时间点

常见分析流程:

  1. 打开 DevTools → Performance 面板
  2. 点击录制按钮(或 Ctrl+E)
  3. 执行你要分析的操作(比如页面加载、滚动、点击)
  4. 停止录制
  5. 在火焰图中找到最耗时的函数调用
  6. 结合 Summary、Bottom-Up、Call Tree 面板分析调用栈

一个实用技巧: 在分析 LCP 问题时,可以在 Performance 面板中勾选 “Screenshots”,录制后直接看到每一帧的渲染截图,直观地看到”什么时候内容才出来”。

5.3 实验室数据 vs 现场数据

这是一个很重要的概念区分:

  • 实验室数据(Lab Data):在受控环境中采集的数据,比如 Lighthouse 跑出来的分数。优点是可复现、方便调试;缺点是不代表真实用户体验。
  • 现场数据(Field Data / RUM Data):在真实用户的设备和网络上采集的数据,也叫 RUM(Real User Monitoring)。优点是反映真实体验;缺点是受设备、网络、地理位置等因素影响,波动大。

面试中如果被问到”你们怎么做性能监控”,最好的回答是两者结合: Lighthouse 用于开发阶段的性能基线检测和 CI 集成,RUM 用于线上真实用户体验的持续监控。


常见误区

误区一:“Lighthouse 跑到 90 分就说明性能好”

Lighthouse 分数是在模拟环境下测出来的,可能和真实用户体验差距很大。真正有说服力的是现场数据(Field Data)——来自真实用户设备和网络的 Web Vitals 数据。而且 Lighthouse 的模拟设备是中等移动设备 + 4G 网络,你的目标用户群体可能完全不同。永远不要只看 Lab Data 就下结论。

误区二:“把所有指标一股脑全报上去”

过度上报不仅浪费带宽和服务器资源,还可能反过来影响页面性能(讽刺吧?你的性能监控代码拖慢了性能)。应该有选择地采集关键指标、合理采样、批量上报。监控系统本身的开销应该尽可能低。

误区三:“FCP 够快就行了”

FCP 只表示”屏幕上出现了第一个内容”,可能只是一个 loading spinner 或骨架屏。用户真正关心的是有意义的主内容何时可见——这是 LCP 的职责。此外,即使 LCP 也不错,如果页面在加载过程中不断抖动(CLS 高)、交互卡顿(INP 高),用户体验依然很差。性能是多维度的,不能只盯一个指标。

误区四:“performance.timing 就够用了”

performance.timing 是 Navigation Timing Level 1 的产物,已经被标记为 deprecated。它只能获取页面导航阶段的时间点,无法采集 LCP、CLS、INP 等现代指标。更重要的是,它是同步读取、一次性返回的,在 SPA 场景下基本没用。现代性能监控应该基于 PerformanceObserver,或者直接用 web-vitals 库。


小结

本章我们从性能指标体系出发,完整梳理了前端性能监控的核心知识。

核心要点

  1. Web Vitals 五大指标:TTFB(服务端响应)、FCP(首次内容渲染)、LCP(最大内容渲染)、INP(交互响应)、CLS(布局稳定性)——它们从不同维度度量用户的真实体验
  2. PerformanceObserver 是现代性能采集的基石:异步、非阻塞、支持各类性能条目,配合 buffered: true 不漏数据
  3. web-vitals 库:Google 官方封装,处理了大量边界情况,生产环境推荐直接使用
  4. Long Task > 50ms 会导致用户感知到卡顿,是 INP 差的主要原因
  5. 上报策略:sendBeacon 发送、visibilitychange 触发、批量缓冲、合理采样
  6. Lab Data + Field Data 结合:Lighthouse 用于开发阶段,RUM 用于线上监控

本章思维导图

前端性能监控
  • 性能指标(Web Vitals)
    • TTFB:服务端响应 + 网络耗时(< 800ms)
    • FCP:首次内容渲染(< 1.8s)
    • LCP:最大内容渲染(< 2.5s)
    • INP:全程交互响应延迟(< 200ms,替代 FID)
    • CLS:累积布局偏移(< 0.1)
  • 数据采集
    • performance.timing(已废弃,了解即可)
    • PerformanceObserver(现代方案,异步 + buffered)
    • web-vitals 库(Google 官方,推荐生产使用)
  • Long Task 检测
    • 定义:主线程 > 50ms 的任务
    • 与 INP 的关系
    • 优化:任务拆分、Web Worker、代码分割
  • 数据上报
    • sendBeacon(推荐,页面卸载安全)
    • visibilitychange 触发上报
    • 批量缓冲 + 采样
    • 空闲上报(requestIdleCallback)
  • 分析工具
    • Lighthouse:模拟环境审计,CI 集成
    • DevTools Performance:火焰图精细分析
    • Lab Data vs Field Data(RUM)

练习挑战

第一题 ⭐:指标识别

请说出以下用户体验问题分别对应哪个 Web Vital 指标:

  1. 用户打开页面后,白屏了 3 秒才出现内容
  2. 用户正在阅读文章,突然页面跳了一下,因为上方的广告加载完了
  3. 用户点击了”提交”按钮,但过了 500ms 页面才有反应
点击查看答案
  1. FCP(如果 3 秒后首次出现任何内容)和 LCP(如果 3 秒后才出现主要内容)。具体要看”出现的内容”是什么——如果是 loading spinner,FCP 可能在 1 秒就完成了,但 LCP 要到 3 秒。
  2. CLS(Cumulative Layout Shift)。广告加载完后撑开容器,导致下方内容被推移,产生意外的布局偏移。
  3. INP(Interaction to Next Paint)。用户交互后 500ms 才有视觉反馈,远超 200ms 的阈值。这通常是因为主线程有长任务阻塞了事件处理。

第二题 ⭐⭐:实现一个简易性能采集器

请基于 PerformanceObserver,写一个采集 FCP 和 LCP 的函数,并在 visibilitychange 时通过 sendBeacon 上报。

点击查看答案
function initPerfMonitor(reportUrl) {
  const metrics = {};

  // 采集 FCP
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.name === 'first-contentful-paint') {
        metrics.fcp = entry.startTime;
      }
    }
  }).observe({ type: 'paint', buffered: true });

  // 采集 LCP(取最后一个)
  new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    metrics.lcp = lastEntry.startTime;
  }).observe({ type: 'largest-contentful-paint', buffered: true });

  // 页面隐藏时上报
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden' && Object.keys(metrics).length > 0) {
      const payload = {
        ...metrics,
        url: location.href,
        timestamp: Date.now(),
        userAgent: navigator.userAgent
      };
      navigator.sendBeacon(reportUrl, JSON.stringify(payload));
    }
  });
}

initPerfMonitor('/api/perf-metrics');

要点:

  • FCP 通过监听 paint 类型获取,过滤 name === 'first-contentful-paint'
  • LCP 会多次触发更新,始终取最后一个值
  • 使用 visibilitychange 而非 unload,因为移动端 unload 可能不触发
  • buffered: true 确保不遗漏已产生的条目

第三题 ⭐⭐⭐:Long Task 与用户交互的关联分析

假设你在生产环境中发现 INP 指标很差(P75 = 450ms),你会如何结合 Long Task 检测来定位问题?请设计一个方案,要求:

  1. 检测所有 Long Task
  2. 当用户发生交互时,判断当前是否有 Long Task 正在执行
  3. 把这个关联信息上报,帮助后端分析”哪些长任务导致了交互延迟”
点击查看答案
function initINPDiagnostics(reportUrl) {
  let activeLongTasks = [];

  // 1. 收集 Long Task 信息
  new PerformanceObserver((list) => {
    const now = performance.now();
    for (const entry of list.getEntries()) {
      activeLongTasks.push({
        start: entry.startTime,
        end: entry.startTime + entry.duration,
        duration: entry.duration
      });
    }
    // 只保留最近 5 秒内的 Long Task(避免内存泄漏)
    activeLongTasks = activeLongTasks.filter(t => now - t.end < 5000);
  }).observe({ type: 'longtask', buffered: true });

  // 2. 监听用户交互,查看交互发生时是否有 Long Task
  const interactionEvents = ['click', 'keydown', 'pointerdown'];
  const reports = [];

  interactionEvents.forEach(eventType => {
    document.addEventListener(eventType, (e) => {
      const interactionTime = performance.now();

      // 找出与当前交互时间重叠的 Long Task
      const blockingTasks = activeLongTasks.filter(task =>
        task.start <= interactionTime && task.end >= interactionTime
      );

      if (blockingTasks.length > 0) {
        reports.push({
          eventType,
          interactionTime,
          targetTag: e.target?.tagName,
          targetId: e.target?.id,
          blockingTasks: blockingTasks.map(t => ({
            duration: Math.round(t.duration),
            waitTime: Math.round(t.end - interactionTime)
          })),
          timestamp: Date.now()
        });
      }
    }, { capture: true, passive: true });
  });

  // 3. 定时 + 页面隐藏时上报
  const flush = () => {
    if (reports.length === 0) return;
    const data = JSON.stringify(reports.splice(0));
    navigator.sendBeacon(reportUrl, data);
  };

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

核心思路:

  • Long Task 的 startTime + duration 确定了任务的时间区间
  • 当用户交互事件触发时,通过 performance.now() 获取当前时间
  • 如果当前时间落在某个 Long Task 的区间内,说明该长任务阻塞了这次交互
  • waitTime(长任务结束时间 - 交互时间)就是用户需要等待的最短时间
  • 后端拿到这些数据后,可以分析”哪些页面、哪些操作最容易被长任务阻塞”
  • 结合 Source Map 和调用栈信息,可以进一步定位到具体的代码

自我检测

读完本章后,对照下面的清单检验自己的掌握程度:

  • 能列出 Web Vitals 五大核心指标(TTFB、FCP、LCP、INP、CLS),并说清楚每个指标衡量的是什么用户体验维度
  • 能解释 FID 和 INP 的区别,以及为什么 Google 用 INP 替代了 FID
  • 能用 PerformanceObserver 采集 FCP、LCP、CLS 数据,并理解 buffered: true 的作用
  • 能说出 web-vitals 库相比手动采集的优势
  • 能解释 Long Task 的定义(> 50ms)和它对用户体验的影响
  • 能说出 sendBeacon 相比 fetch/XHR 在数据上报场景的优势
  • 能解释为什么监听 visibilitychangeunload 更可靠
  • 能区分 Lab Data 和 Field Data,并说出各自的适用场景
  • 能设计一个合理的性能数据上报策略(批量、采样、空闲上报)
  • 能使用 Chrome DevTools Performance 面板录制并分析一个页面的性能瓶颈

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90