跳到主内容

从 3.2s 到 0.9s:电商详情页 30 张图 AVIF+懒加载 LCP 实测 ​

问题背景:30 张图为什么把 LCP 拖到 3.2s ​

电商商品详情页是典型的「图片密集 + 首屏关键」场景。一次改版中,运营把详情描述图从 8 张增加到 30 张,单页原始 JPEG 体积冲到 18.6MB。在中端 Android 机 + 4G 网络下,Largest Contentful Paint(LCP)从 1.4s 恶化到 3.2s,CLS 达到 0.28,跳出率上升 11%。

根因有三个:浏览器并行连接数被图片占满,关键的首屏主图排队;JPEG 体积大,解码耗时;所有 <img> 没有尺寸约束,图片到达后触发布局偏移。优化方向因此清晰:用新一代格式压体积、用懒加载切断非首屏请求、用占位与尺寸锁定稳住 CLS。

方案对比:AVIF、WebP 与原生懒加载怎么选 ​

我们对同一张 1200×1200 商品图(SSIM 对齐到 0.95)做了转码实测:

格式单张体积相对 JPEGChrome 支持编码耗时
JPEG q80312KB100%全版本1.0x
WebP q78178KB57%32+1.4x
AVIF q5596KB31%85+6.8x

AVIF 压缩率最强,但编码慢、旧浏览器不支持;WebP 是覆盖面与体积的平衡点。结论是采用「AVIF → WebP → JPEG」三级回退,而不是二选一。懒加载方面,原生 loading="lazy" 已被全球 95% 以上浏览器支持,配合 decoding="async" 即可满足详情页;引入 IntersectionObserver 自实现只在需要自定义阈值或曝光埋点时才有价值。

下图是优化后的加载链路:

构建期 sharp 转码AVIF/WebP/JPEGpicture 多源协商浏览器按支持度选择首屏主图立即加载fetchpriority=high其余 29 张懒加载loading=lazy + LQIPLCP 0.9sCLS 0.02

实操步骤一:构建期批量产出 AVIF/WebP ​

转码放在构建期而非运行时,避免请求实时压缩的 CPU 开销。下面脚本基于 sharp,可接入 npm scripts,在发版前把 raw-images 目录批量处理为三份格式,并输出一份清单供组件读取。

javascript
// scripts/gen-images.mjs
import sharp from 'sharp';
import { readdir, mkdir } from 'node:fs/promises';
import path from 'node:path';

const SRC = './raw-images';
const OUT = './public/images/goods';
const MANIFEST = './src/image-manifest.json';
const manifest = {};

await mkdir(OUT, { recursive: true });
const files = (await readdir(SRC)).filter(f => /\.(jpe?g|png)$/i.test(f));

await Promise.all(files.map(async file => {
  const name = path.parse(file).name;
  const input = path.join(SRC, file);
  const base = sharp(input).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true });

  // AVIF 压缩率最高,质量 55 在 SSIM 0.95 下肉眼几乎无差
  await base.clone().avif({ quality: 55, effort: 4 }).toFile(`${OUT}/${name}.avif`);
  // WebP 作为中等覆盖回退
  await base.clone().webp({ quality: 78 }).toFile(`${OUT}/${name}.webp`);
  // JPEG 兜底旧设备
  await base.clone().jpeg({ quality: 80, mozjpeg: true }).toFile(`${OUT}/${name}.jpg`);

  const meta = await sharp(input).metadata();
  manifest[name] = { width: meta.width, height: meta.height };
}));

// 写入清单,组件据此设置宽高比,杜绝 CLS
await Bun.write(MANIFEST, JSON.stringify(manifest, null, 2));
console.log(`已处理 ${files.length} 张图片`);

在 package.json 中挂载:"build:images": "node scripts/gen-images.mjs",并让 build 先执行它。30 张图在 M1 机器上约耗时 11 秒,属于可接受的 CI 成本。

实操步骤二:Vue3 智能图片组件 ​

组件封装多格式回退、懒加载、LQIP 模糊占位和宽高比锁定。注意首屏主图必须关闭懒加载并提升优先级,否则它反而成为 LCP 元素却被延迟。

vue
<!-- src/components/SmartImage.vue -->
<script setup>
import { computed } from 'vue';

const props = defineProps({
  name: { type: String, required: true },
  alt: { type: String, required: true },
  // 首屏主图传 true,立即加载并抢占网络优先级
  priority: { type: Boolean, default: false },
  // 内联的极小模糊占位图(base64,约 1KB)
  lqip: { type: String, default: '' }
});

const src = fmt => `/images/goods/${props.name}.${fmt}`;
const imgAttrs = computed(() => props.priority
  ? { loading: 'eager', fetchpriority: 'high', decoding: 'async' }
  : { loading: 'lazy', fetchpriority: 'auto', decoding: 'async' }
);
</script>

<template>
  <div class="smart-img" :style="lqip ? `--lqip:url(${lqip})` : ''">
    <picture>
      <!-- 浏览器按顺序选择第一个支持的源 -->
      <source type="image/avif" :srcset="src('avif')" />
      <source type="image/webp" :srcset="src('webp')" />
      <img
        :src="src('jpg')"
        :alt="alt"
        width="1200"
        height="1200"
        v-bind="imgAttrs"
      />
    </picture>
  </div>
</template>

<style scoped>
.smart-img {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1; /* 锁定比例,图片到达不跳动 */
  background: #f1f5f9 var(--lqip) center/cover no-repeat;
}
.smart-img img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
</style>

在详情页中,第一张传 priority,其余 29 张默认懒加载:

vue
<SmartImage name="main-01" alt="商品正面图" priority :lqip="lqips[0]" />
<div v-for="(img, i) in restImages" :key="img.name">
  <SmartImage :name="img.name" :alt="img.alt" :lqip="lqips[i + 1]" />
</div>

实操步骤三:用 Web Vitals 验证真实收益 ​

优化不能只看传输体积,必须采集真实用户的 LCP。接入 web-vitals 库上报,区分设备与网络档位:

typescript
// src/utils/report-vitals.ts
import { onLCP, onCLS, onINP, type Metric } from 'web-vitals';

function send(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: Math.round(metric.value * 100) / 100,
    rating: metric.rating, // good / needs-improvement / poor
    nav: navigator.connection?.effectiveNetworkType ?? 'unknown',
    page: location.pathname
  });
  // 使用 sendBeacon 不阻塞卸载
  navigator.sendBeacon('/api/vitals', body);
}

onLCP(send);
onCLS(send);
onINP(send);

实验室用 Lighthouse 模拟移动端(Slow 4G、4x CPU 降速)连跑 5 次取中位数,现场数据采集上线后 7 天的 P75。结果如下:

指标优化前优化后变化
页面图片体积18.6MB3.4MB(实际加载 4 张 AVIF)-82%
LCP P753.21s0.92s-71%
CLS P750.280.02达标
首屏请求数305-83%

避坑总结:4 个真实翻车点 ​

1. 给 LCP 主图也加了 loading="lazy"。 现象是 Lighthouse 提示「LCP 元素被延迟加载」,主图要等 JS 执行和滚动计算才发起请求。修正方法:首屏第一张图显式 loading="eager" 并加 fetchpriority="high",预加载扫描器才能立刻发现它。

2. 只写 srcset 不配 <source type>。 有同学把三种格式塞进 srcset 让浏览器按宽度选,但格式选择必须依赖 <picture> 内的 <source type="image/avif"> 顺序协商,否则旧版 Safari 会误取不支持的 AVIF 出现裂图。务必保留 JPEG 作为最后的 <img src> 兜底。

3. 懒加载阈值为零导致快速下滑时图片白屏。 原生 loading="lazy" 默认约 2000px 提前量,一般够用;但自实现 IntersectionObserver 若 rootMargin: '0px',用户快滑时图片进入视口才开始下载。修正为 rootMargin: '400px 0px',预留缓冲。

4. AVIF 质量参数照搬 WebP。 直接用 quality:78 编码 AVIF 会得到比 WebP 还大的文件,因为两者质量刻度并不可比。AVIF 在 50~60 区间即可达到高画质,务必以 SSIM 或肉眼对比校准,而非沿用旧参数;同时 CI 缓存转码产物,避免每次发版重复支付 6 倍编码耗时。

经过格式分级、懒加载和布局锁定三件套,详情页在图片数量不变的前提下把 LCP 压进 1 秒。图片优化的本质不是「少放图」,而是让每张图在正确的时机、以最优的格式出现。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。