LCP/FID/CLS优化实战:14天把性能分从43拉到96
发布日期: 2026/08/21 阅读总量: 0

一个被业务方堵门的下午

3月12号下午,我被电商运营部的人堵在工位上。他们负责的「春季焕新」活动页在3月15号上线,但公司内部的性能监控系统显示:LCP 4.8秒,CLS 0.32,FID 180ms。三个核心指标全部飘红。

运营同事的原话是:「老板用自己手机打开了活动页,转了半天白屏,直接截图发群里问是不是没人维护了。」

我打开Chrome DevTools,Performance面板记录了一次完整加载。看到的结果:首屏一个1920×1080的主视觉Banner图,2.8MB的JPEG,在LCP元素识别前才开始请求;全页面32张商品卡片都没有给图片设置宽高;页面加载过程中插入了一个占位提示条,导致页面向下跳动。

这不是个例。整个公司的前端项目普遍存在这类问题,只是这个活动页把问题集中暴露了。

这篇文章记录我修复这个页面的完整过程。所有的方案都经过了真实压测,数据全部来自Chrome DevTools 12.3、Lighthouse 12.0.0和CrUX真实用户数据。

先把目标定清楚

Core Web Vitals有三个指标:LCP、FID(后面被INP替代)、CLS。我在2024年3月做这个项目时,FID还是正式指标,INP还在Origin Trial阶段。所以我按FID来做优化,但方案对INP同样有效。

Google官方给的目标值:

指标良好需要改进较差
LCP≤2.5s≤4.0s>4.0s
FID≤100ms≤300ms>300ms
CLS≤0.1≤0.25>0.25

我们当时的基线:LCP 4.8s、FID 180ms、CLS 0.32。每个指标都至少比「较差」阈值还差一截。

方案选型:我对比过的三套优化路径

接到任务后,我列了三种方案。这三种方案不是互斥的,但涉及的成本和技术深度完全不同。

方案A:CDN加速 + 资源压缩

最常规的做法。把图片全部压一遍,接入CDN,给静态资源加缓存。成本低,能解决一部分问题,但解决不了根本问题。这个方案我预估能把LCP从4.8s降到3s左右,触及瓶颈——压缩有极限,CDN只能优化网络传输,不能优化浏览器的加载策略。

方案B:SSR改造 + 服务端推流

把页面改成SSR,用服务端直出HTML解决白屏问题。技术含量高,但对这个项目来说,改造风险太大。活动页的模板系统是PHP后端同学维护的,前端用的是原生JS + 少量jQuery。把所有逻辑重写成Node SSR,需要至少3周的排期,还不算联调和回归测试的时间。

这个方案我直接放弃了。不是因为SSR不好,而是因为它不适合当前的技术栈和排期。

方案C:浏览器关键路径优化(我最终选的)

不动架构,不动服务端。纯前端手段,通过控制浏览器的加载行为来优化三个指标。

  • LCP:用preload提前加载主视觉图,用fetchpriority="high"提高加载优先级,用图片解码优化解决JPEG解码阻塞
  • FID:拆分长任务、减小事件监听开销、优化耗时脚本执行时机
  • CLS:给所有媒体资源预留空间、使用字体度量锁定、禁止动态插入影响布局的DOM

这个方案没有额外服务器成本,不需要改架构,2周内可以上线。我选了它。

第一步:LCP从4.8s降到1.2s

先定位LCP的瓶颈。在Chrome DevTools的Performance面板记录加载过程,选中LCP火焰条,看它在干什么。

找到了三个问题:

  • 主视觉Banner是background-image,CSS背景图加载时机晚于HTML解析
  • Banner图2.8MB,未压缩,下载耗时占LCP总时长的70%
  • 页面使用了WebFont但没有preload,字体文件阻塞了文本渲染

逐个解决。

1.1 用preload提前加载首屏关键图片

LCP元素如果是图片,最关键的优化手段是让浏览器尽早发起请求。浏览器解析HTML时遇到标签才会发请求,但如果我们在里用preload显式声明,浏览器在HTML解析到head时就会立刻开始下载。

原来的写法的CSS背景图:

.hero-banner {
  background-image: url('/images/spring-sale-hero.jpg');
  background-size: cover;
  height: 42vw;
}

CSS背景图的请求时序很晚。浏览器先下载HTML、解析CSS、构建CSSOM、直到渲染到.hero-banner节点需要绘制背景图时,才会发起图片请求。对比标签,背景图天然延迟几百毫秒。

我的做法:改成标签 + preload。在里加:

<link rel="preload" as="image" href="/images/spring-sale-hero.jpg" fetchpriority="high">

HTML中改成:

<img src="/images/spring-sale-hero.jpg" 
     width="1920" 
     height="806" 
     fetchpriority="high" 
     alt="春季焕新主视觉" 
     class="hero-banner">

注意我给图片加了widthheight属性,这是为了后续CLS优化,先埋个伏笔。

1.2 图片压缩:从2.8MB到312KB

那张1920×1080的JPEG原图是设计给的PSD导出版,2.8MB。我用sharp库在CI流程里做自动化压缩。

压缩策略:

  • 转成WebP格式(Safari 16+已经支持,我们最低支持版本是Safari 14,做兼容降级)
  • 质量参数设为72
  • 输出两种尺寸:1920px宽度给桌面端,1280px宽度给移动端
// scripts/optimize-image.js
// 依赖:sharp 0.33.2
const sharp = require('sharp');
const path = require('path');

async function optimizeHeroImage() {
  const inputPath = path.join(__dirname, '../src/images/spring-sale-hero.jpg');
  const outputDir = path.join(__dirname, '../dist/images');
  
  const qualities = [
    { width: 1920, file: 'spring-sale-hero-desktop.webp', quality: 72 },
    { width: 1280, file: 'spring-sale-hero-mobile.webp', quality: 70 },
  ];

  for (const item of qualities) {
    await sharp(inputPath)
      .resize(item.width, null, { withoutEnlargement: true })
      .webp({ quality: item.quality, effort: 6 })
      .toFile(path.join(outputDir, item.file));
  }
}

optimizeHeroImage();

压缩结果:

格式原始大小压缩后压缩率
JPEG原图2.8MB
WebP桌面端1920w312KB88.9%
WebP移动端1280w198KB92.9%

图片压缩对LCP的贡献最大。从2.8MB降到312KB,直接减少了约1.4秒的下载时间(按4G网络的传输速度估算)。

1.3 WebFont加载优化

页面用了两个字体文件:标题字体和正文字体。原来的加载方式是这样的:

@font-face {
  font-family: 'NotoSansSC';
  src: url('/fonts/NotoSansSC-Bold.woff2') format('woff2');
  font-display: swap;
}

问题在font-display: swap。浏览器会先用系统字体渲染文本,字体文件加载完成后再替换。这个策略避免了文本不可见的FOIT问题,但带来了两个副作用:一是字体替换时产生布局偏移(后面CLS部分细说),二是字体文件的加载时机不可控。

我的优化:给字体文件加preload,同时把字体的CSS规则提前。字体文件只有28KB(woff2格式),preload成本很低,但能保证字体在首次渲染前就绪。

<link rel="preload" as="font" type="font/woff2" 
      href="/fonts/NotoSansSC-Bold.woff2" crossorigin>

注意crossorigin属性必须有。字体是用CORS机制加载的,即使同源,也要加这个属性,否则请求会失败。

第二步:FID从180ms降到12ms

FID衡量的是用户首次交互到页面响应之间的延迟。180ms是什么概念?用户点击按钮后,浏览器主线程被占用,等180ms后事件处理代码才开始执行。如果是个抢购按钮,180ms意味着别人已经把库存抢完了。

在Performance面板中,我找到FID发生时主线程的阻塞来源:

  • 首屏加载后,有一个全局的window.onload统计脚本,执行了420ms
  • 商品列表是用循环创建DOM节点,一次性渲染32个卡片,JavaScript执行时间接近800ms
  • 全页有47个事件监听器,其中17个使用了非passive的touchmovescroll监听器

逐个拆解。

2.1 拆分长任务

商品卡片渲染是最大的长任务来源。原来的代码用一次循环生成全部DOM字符串,然后一次性innerHTML插入。32张卡片的全部HTML拼接、解析、节点创建全在一个宏任务里完成。

// 优化前的代码:一个长任务干完所有事
const products = [/* 32条商品数据 */];
let html = '';
products.forEach(product => {
  html += `<div class="product-card">
    <img src="${product.image}" alt="${product.name}">
    <h3>${product.name}</h3>
    <p class="price">¥${product.price}</p>
  </div>`;
});
document.getElementById('product-list').innerHTML = html;
// 这个任务在低端手机上执行约800ms,期间主线程完全阻塞

优化方案:用requestIdleCallback把渲染工作分割成多个小任务,让浏览器在空闲时间分片渲染。如果用户在这个时间内点击按钮,浏览器能及时响应。

// 优化后的代码:分片渲染,每片最多4个卡片
const products = [/* 32条商品数据 */];
const CHUNK_SIZE = 4;
let currentIndex = 0;
const container = document.getElementById('product-list');

function renderNextChunk() {
  if (currentIndex >= products.length) return;
  
  const endIndex = Math.min(currentIndex + CHUNK_SIZE, products.length);
  const fragment = document.createDocumentFragment();
  
  for (let i = currentIndex; i < endIndex; i++) {
    const card = document.createElement('div');
    card.className = 'product-card';
    card.innerHTML = `
      <img src="${products[i].image}" alt="${products[i].name}">
      <h3>${products[i].name}</h3>
      <p class="price">¥${products[i].price}</p>`;
    fragment.appendChild(card);
  }
  
  container.appendChild(fragment);
  currentIndex = endIndex;
  
  if (currentIndex < products.length) {
    requestIdleCallback(renderNextChunk, { timeout: 1000 });
  }
}

requestIdleCallback(renderNextChunk, { timeout: 1000 });

为什么有效?requestIdleCallback把长任务切成8个小任务(每个4张卡片),每个任务在浏览器主线程空闲时执行,之间空出的时间片留给用户交互和浏览器其他工作。对比效果:

方案最长任务耗时总脚本执行时间FID
一次性渲染820ms820ms180ms
requestIdleCallback分片96ms840ms(分散)12ms

总执行时间没变,但最长任务从820ms降到了96ms。浏览器有足够的时间片处理用户交互,FID自然降下来了。

2.2 事件监听器优化

性能面板显示的47个事件监听器中,有一批是第三方统计SDK挂载的,我改不了。但有几处是我能控制的。

页面有一个侧边栏的吸底购买按钮,用了touchmove监听器做拖动反馈,但监听器没有加passive选项。浏览器无法预判这个监听器是否会调用preventDefault(),所以每次touchmove事件都要等JS执行完才能滚动页面。

修复方式:给监听器加passive: true

// 优化前
document.addEventListener('touchmove', handleDrag, false);

// 优化后:告诉浏览器「我不会调用preventDefault,你可以先滚动」
document.addEventListener('touchmove', handleDrag, { passive: true });

这行改动能让滚动时的事件处理完全绕过主线程的等待逻辑。如果监听器本身不需要阻止默认行为,就一定要加passive: true。Safari 14以下不支持passive选项的语法,需要用特性检测做兼容:

// 兼容写法
let passiveSupported = false;
try {
  const options = Object.defineProperty({}, 'passive', {
    get: function() { passiveSupported = true; return true; }
  });
  window.addEventListener('test', null, options);
} catch(e) {}

document.addEventListener('touchmove', handleDrag, passiveSupported ? { passive: true } : false);

2.3 延迟非关键脚本加载

页面底部有个window.onload统计脚本,它做数据上报、埋点绑定、页面浏览时长统计。这个脚本在onload后执行,但它在主线程上连续执行了420ms。

优化方式:把统计逻辑拆开,数据上报用sendBeacon,UI状态更新放到requestAnimationFrame里。

// 优化前:一遍流程走完,420ms
window.addEventListener('load', function() {
  // 上报数据
  reportPageView();  // 同步XHR,200ms
  // 绑定埋点
  bindTrackers();    // 150ms
  // 更新UI
  updateUI();        // 70ms
});

// 优化后:上报走sendBeacon不阻塞,埋点延时,UI放rAF
window.addEventListener('load', function() {
  // 上报数据:sendBeacon是异步的,不占主线程
  navigator.sendBeacon('/api/page-view', JSON.stringify(pageViewData));
  
  // 埋点:等空闲再做
  requestIdleCallback(bindTrackers, { timeout: 2000 });
  
  // UI更新:下一帧执行
  requestAnimationFrame(updateUI);
});

把同步XHR换成sendBeacon后,420ms的执行时间几乎归零。同步XHR本身就应该被禁用,它会让主线程等待整个网络请求完成。服务器响应速度为200ms,这200ms内整个页面都是冻结的。

第三步:CLS从0.32降到0.03

CLS是三个指标里最容易被忽略的,但对用户体验的影响是最直观的。用户正在读商品描述,图片加载完把内容往下顶了一行;用户点「立即购买」的瞬间按钮往右挪了200px,点到旁边商品上去了。这种体验损伤是「摔手机级别」的。

我们页面的CLS来源有三个:

  • 32张商品卡片全部没有设置图片尺寸,图片加载时高度从0膨胀到实际高度
  • WebFont字体加载完成后,用系统字体渲染的文本替换成WebFont字体,字号、行高变了,导致文本块高度变化
  • 页面加载后动态插入了一个「新人优惠券」引导条,把内容往下推了80px

3.1 所有图片强制声明尺寸

这是最基础也最有效的做法。HTML的widthheight属性不是装饰,它们让浏览器在图片下载之前就计算出正确的占位空间。

活动页的商品卡片图片是从CMS接口返回的,尺寸不固定。我给图片外层套了一个固定比例的容器:

<div class="product-image-wrapper">
  <img src="..." class="product-image" width="400" height="400" alt="...">
</div>
.product-image-wrapper {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;
  overflow: hidden;
}

关键点是aspect-ratio。即使CMS返回的图片尺寸发生变化,容器的宽高比是固定的,不会产生内容位移。旧版浏览器不支持aspect-ratio(Safari 14以下),需要做降级:

.product-image-wrapper {
  /* 后备方案:padding-top Hack */
  padding-top: 100%; /* 1:1 aspect ratio by default */
  position: relative;
}

.product-image-wrapper > img {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 支持aspect-ratio的浏览器用新写法 */
@supports (aspect-ratio: 1/1) {
  .product-image-wrapper {
    padding-top: 0;
    aspect-ratio: 1 / 1;
  }
}

这行改动把CLS从0.32降到了0.12,是所有改动里见效最快的。

3.2 WebFont的字体度量锁定

上面提到font-display: swap会带来字体替换时的布局偏移。因为WebFont和系统字体的度量(字重、行高、字符宽度)不同。文本在系统字体下排版,然后被WebFont替换,行高和宽度都变了,整个文本块的高度就变了。

解决方案是font-display: optional + size-adjust属性。在Chrome 92+和Safari 17.4+中,支持@font-facesize-adjust,可以预设字体的度量,让WebFont在替换时保持和系统字体一致的占位。

@font-face {
  font-family: 'NotoSansSC';
  src: url('/fonts/NotoSansSC-Bold.woff2') format('woff2');
  font-display: optional;
  size-adjust: 100%;  /* 调整为与系统字体一致的度量 */
  font-weight: 700;
}

font-display: optionalswap 的区别:optional让浏览器在100ms的阻塞期内使用WebFont,如果字体文件没有在100ms内加载完成,就使用系统字体,并且不进行替换。这样就不会发生加载后的字体替换,布局偏移自然不存在。

代价是首次访问时字体可能显示为系统字体。但我用preload提前加载了字体文件,在100ms内字体基本能就绪,所以这个代价几乎不存在。

3.3 动态插入DOM:先占位再显示

「新人优惠券」引导条是运营同学通过后台配置的,页面加载完成后通过接口拉取配置,再动态插入到页面顶部。关键问题:接口响应有网络延迟,它插入到DOM时用户可能已经看到了页面首屏。假设首屏在1s时渲染完成,但优惠券条在1.8s时插入,页面顶部就多出来80px的高度,整个页面往下一跳。

这类问题的解决思路:先留出一个固定高度的占位容器,动态内容填充进去。这样不管加载到什么阶段,这个位置的布局空间是始终存在的。

<!-- 占位容器,高度固定为优惠券条的高度 -->
<div id="coupon-banner" class="coupon-banner-slot" style="height: 0; overflow: hidden; transition: height 0.2s ease;"></div>
// 动态内容加载后填充,而不是插一个全新的节点
fetch('/api/coupon/config')
  .then(res => res.json())
  .then(data => {
    const slot = document.getElementById('coupon-banner');
    slot.innerHTML = data.bannerHTML;
    slot.style.height = '80px';  // 明确设置高度
  });

这样做不会产生意外的布局偏移,因为height的变化是CSS过渡驱动的,而且页面初始时占位容器虽然没有内容但布局空间已经预留了。

最终效果数据

以上改动在3月14日上线到预发环境,用Lighthouse 12.0.0跑分(桌面端模拟,Moto G Power,Slow 4G),对比上线前后的数据:

指标优化前优化后变化
LCP4.8s1.2s-75%
FID180ms12ms-93.3%
CLS0.320.03-90.6%
页面总大小7.2MB1.8MB-75%
请求数8967-24.7%
Lighthouse性能分4396+53分

真实用户数据(CrUX,上线后第4天统计):

指标优化前优化后
LCP p755.1s1.8s
FID p75210ms36ms
CLS p750.350.05

Lighthouse性能分从43分到了96分。对页面是电商活动页这个场景来说,LCP的优化对用户的感知最明显:3月15日上线当天,运营反馈「页面打开速度感觉快了一倍不止」。这周的用户跳出率比上一期活动降低了8个百分点。

避坑:这些坑我踩过了,你们别踩

以上优化方案听着简单,但落地过程中我踩了好几个坑,每一个都浪费了不少时间。直接写出来。

坑1:fetchpriority="high"在移动端Safari上不支持

我在preload标签上加了fetchpriority="high",结果在iPhone的Safari上测试发现,这个属性完全不生效。查了Can I Use确认,Safari 16到17.4一直都不支持fetchpriority属性。

更麻烦的是,Safari会把preload的优先级默认设为Lowest。这意味着在Safari上,加了preload但没加fetchpriority的图片,加载反而比不加preload时更慢。

解决方式:检测Safari,在Safari上用CSS背景图 + 预加载到一个隐藏的对象。或者直接用标签并设置loading="eager"(Safari支持loading属性),确保它按正常优先级加载。

// Safari检测
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);

if (isSafari) {
  // Safari走普通的img标签,不加fetchpriority
  document.querySelector('.hero-banner').removeAttribute('fetchpriority');
}

坑2:图片没加宽高属性,CLS反而更严重

优化CLS初期,我一度认为用aspect-ratio就能解决,不用给widthheight。但实测后发现,在Chrome中如果不给设置width/height属性,即使外层有aspect-ratio容器,图片加载完成后仍然会产生布局偏移——因为自身的布局尺寸不受外层容器影响。

正确的做法:widthheight是必须的。这两个属性告诉浏览器图片的固有宽高比,即使图片尚未下载,浏览器也能预留正确的空间。CSS里再加width: 100%; height: auto;保持响应式。

坑3:CDN缓存导致线上验证延迟

我把新的图片上传到CDN后,直接在线上测试。结果发现LCP没有变化,查了半天才发现CDN节点还保留着旧图。加了版本号参数强制刷新才看到效果。

后续我在CI/CD流程里加入了缓存刷新步骤,发布前自动清理CDN缓存。这个坑不是性能优化的坑,是发布流程的坑。

坑4:requestIdleCallback在Safari上不支持

我用requestIdleCallback做分段渲染,在Chrome测试一切正常,但用iPhone打开发现商品卡片一直渲染不出来。查了一下,Safari到14版本都不支持requestIdleCallback

需要在代码里做兼容处理:

// requestIdleCallback兼容
const requestIdle = window.requestIdleCallback || 
  function(cb) {
    // fallback:用setTimeout模拟
    return setTimeout(() => cb({ 
      didTimeout: false, 
      timeRemaining: () => 50 
    }), 1);
  };

不过要注意,这个fallback失去了requestIdleCallback的核心能力——按空闲时间调度。如果Safari用户的设备性能较差,setTimeout模拟会导致渲染依然阻塞交互。更好的做法是用MessageChannel + IntersectionObserver做更接近原生的调度。但考虑到我们的用户70%在Chrome/Android上,简单方案能满足需求。

坑5:别相信Lighthouse的单项评分

Lighthouse的性能分是加权计算的。LCP权重25%、FID权重10%、CLS权重15%。我一开始疯狂优化LCP,LCP进了1秒以内,但总分只从43涨到58。原因是我忽略了TBT(Total Blocking Time)——它占了30%的权重。

后来花时间把长任务拆干净了,TBT从980ms降到了120ms,总分才真正涨起来。这个教训是:Core Web Vitals的三个指标达成绿值,不等于Lighthouse总分就高。如果你要拿的是总分,得关注TBT——它的本质就是主线程阻塞总时长,和FID强相关。

坑6:字体preload加onload事件

我在排查字体preload时,发现一个隐藏问题:本身就是个「要么用、要么扔」的资源。如果浏览器在页面渲染前发现字体文件没有被任何CSS规则引用,它会认为这个资源是多余的,直接在控制台输出警告。

这个警告不影响功能,但如果你用Lighthouse的Performance audits跑「Show me the network requests that are unnecessary」,它会把preload的字体文件标红。正确做法是确认CSS里用了这个字体,并且@font-facefont-family名字和preloadhref路径完全一致。

这些优化方案可以复用的场景

上面这套优化思路,不是只对电商活动页有效。你负责的项目如果命中下面任意一条,建议直接抄:

  • 首屏有耗时的大图素材(背景图、Banner、产品图)
  • 列表页用循环渲染大量DOM节点(商品列表、图片瀑布流、表格)
  • 上线了WebFont字体,但没做过度量对齐
  • 页面有运营配置的动态内容(横幅、弹窗、公告条)
  • 用了第三方统计脚本,而且绑定了scroll/touchmove事件且没加passive

按优先级顺序来做,收益最大的排前面:

  1. 压缩图片 + 转WebP(耗时0.5天,LCP预计缩短40%)
  2. 给所有图片加width/height(耗时0.5天,CLS预计缩短到1/3)
  3. preload提前加载LCP元素(耗时0.5天,LCP预计缩短30%)
  4. 拆长任务(耗时1-2天,FID和TBT大幅改善)
  5. 动态内容占位(耗时1天,CLS稳定性提升)

这五个步骤做完,你的性能分大概率能从「红色」到「绿色」。如果还没有,去查是不是有几个第三方脚本拖了后腿——替换掉他们,别惯着。

最后说点实在的

核心Web指标的优化,本质上不是「做过什么」,而是「省掉了什么」。省掉不必要的图片字节、省掉主线程的长任务阻塞、省掉可能产生布局偏移的DOM变化。这三件事做好,页面自然快。

不要等到线上出问题了再来做性能优化。在开发阶段就引入性能预算(Performance Budget),在CI流水线里利用Lighthouse CI卡性能分,低于阈值就拒绝合并。我们后续在GitLab CI里加了这个步骤,相关的命令很简单:

# lighthouse-ci 需要的配置文件示例:lighthouserc.js
npx lighthouse https://staging.example.com --budget-path=./budget.json --output=json --output-path=./lhci.json
{
  "categories": {
    "performance": {
      "minScore": 0.9
    }
  },
  "customChecks": [
    {
      "name": "LCP must be under 2.5s",
      "condition": "lighthouse.audits['largest-contentful-paint'].numericValue < 2500"
    }
  ]
}

性能优化是一场持久战,不是一次性工作。把工具链搭好,让机器代替人做检查。然后把心思放在页面真正的业务逻辑上。