图片是 Web 性能监控的重灾区:LCP 和 CLS 到底怎么测、怎么定位到具体那张图

做前端性能这几年,我踩过一个反复出现的坑:本地 Lighthouse 跑出来 95 分,一到线上真实用户那边,投诉页面"卡""跳"的却一堆。后来盯着数据看明白了------问题几乎都出在图片上,而且实验室环境根本复现不出来。

这篇就把"图片相关的 Web 性能怎么测、怎么监控、怎么定位到罪魁祸首那张图"讲清楚。重点是测量和监控这条链路,不是又一篇"图片懒加载十种写法"。

先分清两组概念:实验室数据 vs 真实用户数据

这是很多人一上来就混的地方,先掰开。

实验室数据(Lab):Lighthouse、WebPageTest、PageSpeed Insights 里的模拟测试。它在一个固定的模拟网络、固定设备档位下跑一次,结果稳定、可复现、适合在 CI 里做回归卡点。缺点是它只是"一台虚拟机跑一遍",跟你的真实用户群体八竿子打不着。

真实用户数据(RUM,Real User Monitoring):在真实用户的浏览器里采集,用户是什么网、什么手机、滑到哪、点了啥,全是真的。它反映的是业务真相,但数据噪声大、有归因难度、需要自己搭上报和看板。

一句话:Lab 用来防回归,RUM 用来看真相。两个都要,别指望一个顶俩。图片问题尤其如此------实验室网络太干净,一张 2MB 没压缩的大图在模拟的快网下可能只慢 200ms,到了用户的弱网就是白屏两秒。

Core Web Vitals 里跟图片强相关的三个指标

Google 那套 Core Web Vitals,跟图片直接挂钩的主要是这几个:

  • LCP(Largest Contentful Paint,最大内容绘制):视口内最大的那块内容画出来的时间。绝大多数页面,这个"最大内容"就是首屏那张主图/banner。所以 LCP 优化,本质上八成是在优化图片。好的阈值是 2.5 秒以内。
  • CLS(Cumulative Layout Shift,累积布局偏移) :页面元素意外跳动的累计程度。图片是 CLS 的头号元凶------<img> 没写宽高,图一加载出来把下面的内容往下一顶,用户正要点的按钮跑了。好的阈值是 0.1 以内。
  • INP(Interaction to Next Paint,交互到下次绘制):衡量交互响应。跟图片关系稍弱,但如果你在滚动时用 JS 疯狂解码大图、频繁重排,也会拖累它。

这三个里,LCP 和 CLS 是图片直接决定的,本文重点讲这两个怎么测、怎么归因。

用 web-vitals 库采集:最省事的入口

Google 官方的 web-vitals 库把三大指标的采集封装好了,兼容各浏览器差异,是采集的首选。它上报的是"用户真实感受到的值",不是模拟值。

js 复制代码
// npm i web-vitals
// 注意:用 onLCP / onCLS 这套新 API(v3+),别用老的 getLCP
import { onLCP, onCLS, onINP, onFCP, onTTFB } from 'web-vitals';

// 统一的上报函数:优先用 sendBeacon,页面卸载时也能把数据发出去
function report(metric) {
  const body = JSON.stringify({
    name: metric.name,          // 'LCP' / 'CLS' / ...
    value: metric.value,        // 指标数值(LCP 是毫秒,CLS 是无单位分值)
    rating: metric.rating,      // 'good' / 'needs-improvement' / 'poor'
    id: metric.id,              // 本次页面加载的唯一 id,用于去重
    page: location.pathname,
    // navigationType 能区分是首次进入、刷新还是 bfcache 恢复
    navType: metric.navigationType,
  });

  // sendBeacon 不阻塞卸载,发送成功率比 fetch 高
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/rum/collect', body);
  } else {
    fetch('/rum/collect', { body, method: 'POST', keepalive: true });
  }
}

// 关键:这些指标是"渐进上报"的
// CLS 会累积到页面隐藏才是最终值,LCP 也可能被后到的更大元素刷新
// 所以回调可能触发多次,服务端要按 metric.id 取最后一次
onLCP(report);
onCLS(report);
onINP(report);
onFCP(report);
onTTFB(report);

这里有个新手常犯的错:以为回调触发一次就是最终值。CLS 是累积的,要等页面进入隐藏状态(用户切走或关闭)才定稿 ;LCP 也可能因为一张晚到的大图被刷新。所以同一个 metric.id 可能上报多次,服务端要以最后一条为准,别去做平均。

深入一层:用 PerformanceObserver 挖出"是哪张图"

web-vitals 给你的是一个数字:"LCP 是 3200ms"。但要修,你得知道那 3200ms 是花在哪张图上的 。这就得下沉到 PerformanceObserver

LCP 的 entry 里带着 elementurl,能直接指到那个元素和那张图的地址:

js 复制代码
// 监听 LCP 候选元素,拿到具体是哪张图
const lcpObserver = new PerformanceObserver((list) => {
  // 取最后一个 entry ------ LCP 候选会不断更新,最后一个才是当前最大内容
  const entries = list.getEntries();
  const last = entries[entries.length - 1];

  console.log('[LCP] 元素:', last.element);          // 具体 DOM 节点
  console.log('[LCP] 图片地址:', last.url);           // 如果是图片,这就是图源 URL
  console.log('[LCP] 渲染时间:', last.renderTime || last.loadTime);
  // renderTime 有时因跨域图缺 Timing-Allow-Origin 头而为 0,用 loadTime 兜底
});

// buffered: true 很重要 ------ 能拿到 observer 注册之前就已经发生的 entry
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

跨域图片这里有个隐藏坑:如果图片来自 CDN 或别的域名,且没返回 Timing-Allow-Origin 响应头,renderTime 会被打成 0(防时序侧信道)。想拿到准确的图片加载耗时,图源那边得配上这个头。

CLS 归因更实用------它的 entry 里有 sources,直接告诉你是哪几个元素在跳

js 复制代码
// 监听布局偏移,揪出跳动的元凶元素
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 用户主动交互后 500ms 内的偏移不算 CLS(比如点击展开),跳过
    if (entry.hadRecentInput) continue;

    console.log('[CLS] 本次偏移分值:', entry.value);
    // sources 就是这次偏移里"动了"的元素,逐个打出来
    for (const source of entry.sources) {
      console.log('[CLS] 跳动元素:', source.node);       // 罪魁 DOM 节点
      console.log('[CLS] 从', source.previousRect, '跳到', source.currentRect);
    }
  }
});

clsObserver.observe({ type: 'layout-shift', buffered: true });

跑一遍你多半会发现:跳动的 source.node 十有八九是个没写尺寸的 <img>。图一到位就把布局撑开,下面全被顶下去。修法也简单------给图片显式写 width/height 属性(或 CSS 的 aspect-ratio),让浏览器加载前就预留好位置。

再补一个直接查图片资源耗时的观察器,能量化"哪张图下载最久、传输了多少字节":

js 复制代码
// 通过 Resource Timing 统计每张图片的加载耗时和体积
const resObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.initiatorType !== 'img') continue;   // 只看图片资源

    const duration = Math.round(entry.duration);          // 总耗时 ms
    const transferKB = Math.round(entry.transferSize / 1024); // 实际传输大小

    // 阈值告警:超过 500ms 或 300KB 的图,上报出来重点盯
    if (duration > 500 || transferKB > 300) {
      report({
        name: 'SLOW_IMAGE',
        value: duration,
        url: entry.name,           // 图片 URL
        sizeKB: transferKB,
        page: location.pathname,
      });
    }
  }
});

resObserver.observe({ type: 'resource', buffered: true });

transferSize 是实际走网络的字节数(已含压缩)。如果它跟图片原始尺寸差不多,说明这张图根本没压缩,直接原图上线了------这是线上很常见、也很容易修的浪费。

定位图片瓶颈的三类典型元凶

把上面的数据跑起来,你能定位到的问题基本逃不出这三类:

  1. 大图未压缩 / 格式老transferSize 很大,或者跟 decodedBodySize 接近。表现是 LCP 高。修法是压缩、上 WebP/AVIF、按视口给合适尺寸(srcset)。
  2. 图片没预留尺寸 → CLS 高layout-shift 的 source 反复指向 <img>。修法是显式宽高 / aspect-ratio
  3. 首屏主图被延后加载 :明明是 LCP 元素,却被挂了 loading="lazy",或者排在一堆非关键资源后面才请求。首屏主图恰恰不该 懒加载,该用 fetchpriority="high" 甚至 <link rel="preload"> 提前。

一个实操建议:把 LCP 的 url 和 Resource Timing 的图片数据关联起来,你就能画出"LCP 图片从发起请求到渲染完成"的完整瀑布,卡在下载还是卡在解码、卡在排队,一目了然。

把数据变成能长期盯的 RUM 看板

零散地 console.log 只能救急。要持续监控,得把上报数据落库 + 看板。链路大概是:

浏览器采集 → sendBeacon 上报到收集端 → 写入时序库 → 看板按维度切。

看板上真正有用的几个维度:

  • 看 P75,不看平均值。Core Web Vitals 官方口径就是 75 分位。平均值会被少数极端值和大量好样本一起抹平,掩盖掉那 25% 体验差的用户。
  • 按页面路径 / 设备类型 / 网络类型 分组。往往就是某个落地页的某张 banner、或者只有安卓弱网用户中招。全局一个数看不出来。
  • 趋势线 + 发版打点。哪次发版把 LCP 顶上去了,图上一眼看到。

采集端可以自己用 Node 收 sendBeacon,也可以直接用现成的 RUM 服务(很多云监控自带 Web Vitals 采集)。自建的好处是数据自己攥着、能跟业务字段(用户分层、AB 分组)打通。

顺手提一句:图片压缩这一环别省

监控只是"发现问题",最后还得动手压。批量把大图压小、转 WebP/AVIF、生成多尺寸,工具链不复杂:命令行的 sharpcwebp 适合接进构建流水线;临时手动处理,浏览器里就能跑的有 squoosh、tinypng、tudingai.cn 这类,拖进去出结果,不用装环境。选哪个看场景,核心是别把没压过的原图直接怼上线------这是 LCP 数据里最扎眼、也最容易还的技术债。

几条诚实的边界,别被数据骗了

写监控这么久,有几个坑得提前说,不然容易自我感觉良好:

  • 实验室达标 ≠ 真实用户达标。Lighthouse 满分,RUM 的 P75 可能照样飘红。以 RUM 为准。
  • 采样率是把双刃剑。全量上报数据量和成本吃不消,通常抽样(比如 10%)。但抽样会引入统计噪声,低流量页面的数据可能就几十个样本,别拿它下结论。
  • 归因不是百分百准layout-shift 的 source、LCP 的 element 是很强的线索,但复杂页面里一次偏移可能牵连多个元素,最终还得人肉复现确认。
  • 指标不是全部 。CLS 0.1、LCP 2.5s 是通用参考线,不是业务目标本身。有的页面 LCP 稍高但转化没受影响,就别为了刷分过度优化。指标是手段,用户体验和业务才是目的
  • 数据只覆盖"能跑起来的用户"。彻底加载失败、白屏到用户直接关掉的那批人,可能连上报都发不出来------最差的体验反而在你的数据里是缺失的。这个盲区要心里有数。

小结

图片相关的 Web 性能监控,可以浓缩成一条链路:web-vitals 采集三大指标 → PerformanceObserver 下钻到具体元素和图源 → Resource Timing 量化每张图的耗时体积 → sendBeacon 上报 → RUM 看板按 P75 分维度盯。LCP 高多半是大图没压、主图被延后;CLS 高基本是图没写尺寸。定位到了就好办。

但记住最后那句:实验室防回归,真实用户看真相,指标是手段不是目的。把数据当罗盘,别当圣旨。

相关推荐
谙忆102412 小时前
图片预加载与 fetchpriority 实战:吃透浏览器资源优先级抢到 LCP 图
性能优化
TPBoreas14 小时前
MySQL性能优化面试全攻略
mysql·面试·性能优化
一孤程21 小时前
PerfDog性能测试实战——从入门到性能优化全覆盖
性能优化·性能测试·测试·perfdog
谙忆10241 天前
图片懒加载实战:IntersectionObserver、LQIP 占位与 CLS 布局防抖
性能优化
丁小未2 天前
基于MVVM框架的XUUI HelloWorld 新手教程
unity·性能优化·c#·游戏引擎
飞天狗2 天前
长列表滚动卡顿怎么破:虚拟列表选型 + react-virtuoso 实战调优指南
react.js·性能优化
zhangjw342 天前
第29篇:Java伪共享与对象分配:并发性能优化的关键
java·开发语言·性能优化
SelectDB3 天前
美团数十 PB 规模 Apache Doris 实践:从统一 OLAP 到 AI-Native 数据基座
大数据·数据库·性能优化
Patrick_Wilson3 天前
最佳实践是有保质期的:从一次 CDN external 白屏事故说起
前端·性能优化·前端工程化