外观
从 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)做了转码实测:
| 格式 | 单张体积 | 相对 JPEG | Chrome 支持 | 编码耗时 |
|---|---|---|---|---|
| JPEG q80 | 312KB | 100% | 全版本 | 1.0x |
| WebP q78 | 178KB | 57% | 32+ | 1.4x |
| AVIF q55 | 96KB | 31% | 85+ | 6.8x |
AVIF 压缩率最强,但编码慢、旧浏览器不支持;WebP 是覆盖面与体积的平衡点。结论是采用「AVIF → WebP → JPEG」三级回退,而不是二选一。懒加载方面,原生 loading="lazy" 已被全球 95% 以上浏览器支持,配合 decoding="async" 即可满足详情页;引入 IntersectionObserver 自实现只在需要自定义阈值或曝光埋点时才有价值。
下图是优化后的加载链路:
实操步骤一:构建期批量产出 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.6MB | 3.4MB(实际加载 4 张 AVIF) | -82% |
| LCP P75 | 3.21s | 0.92s | -71% |
| CLS P75 | 0.28 | 0.02 | 达标 |
| 首屏请求数 | 30 | 5 | -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 秒。图片优化的本质不是「少放图」,而是让每张图在正确的时机、以最优的格式出现。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。