一次大促前的压测,把我们的首页老底翻了个彻底:4G 网络下首屏平均要 6.8 秒才能稳定,运营说页面卡得没法看,后端接口却都在 200ms 以内。真正把时间吃掉的是商品图片——12 个商品卡将近 9MB 的图片资源,一半以上是原图直出,或者是设计师手工导出的超大 JPG。这次之后,我开始系统性地做零售商品图片的智能压缩与优化,核心目标只有一个:在不牺牲视觉观感的前提下,最大限度提升页面加载速度。这篇技术实践分享,就是这次改造从原理、选型、落地到后期排坑的完整复盘,适合电商前端、后端、性能优化工程师,以及所有被“图片拖慢页面”折磨过的人。
1. 商品图到底有多重:零售场景里的性能账本
1.1 一个 SKU 不只是一张图
很多非电商背景的工程师,对商品图的体量是没有概念的。以为图片优化就是找几张 banner 压缩一下,实际上零售商品图是一个典型的“海量小文件”场景,而且数量级远超想象。
一个普通 SKU 通常包含这些图片:白底主图、模特图、场景图、细节特写、包装图、尺码表,有的还有 360 度展示图。单张摄影棚原图轻松到 3-8MB,一个 SKU 积累下来 20-30MB 很常见。如果店铺有几千个 SKU,再加上历史版本、活动素材、不同运营小组重复上传的素材,图片服务的存储量和访问量都会迅速膨胀。
这带来的第一个问题就是存储成本,第二个问题才是页面性能。很多团队会把注意力放在接口优化、首屏 JS 拆包上,结果一查网络面板,发现图片字节数占比常常超过 70%。接口再快,用户的眼睛还是被图片下载时间卡住。
1.2 页面加载速度的瓶颈为什么轮到图片
这里有个很反直觉的点:图片不像 JS 和 CSS,它没法做常规的文本压缩。HTTP gzip 对图片基本无效,因为图片编码以后已经是高度压缩的二进制数据,再压一遍很难有明显收益。而且图片还有一个特性是“必须整张下载完才能渲染出一部分”,虽然现代浏览器支持渐进式 JPEG,但大部分场景下,一张大图就是 LCP 的绝对瓶颈。
LCP,也就是 Largest Contentful Paint,指的是用户可见区域内最大元素的渲染时间。在零售页面里,这个最大元素十有八九是商品图或者运营 banner。我们当时用 Lighthouse 跑了一遍商详页,LCP 元素就是轮播图里的第一张商品主图,那张图原图 2.8MB,光下载就花了 1.8 秒。
所以只要图片不优化,其他前端优化做得再好,用户感知到的加载速度还是慢。首屏性能问题,本质上是图片性能问题。
1.3 先定预算再行动
做图片优化最忌讳的就是“拍脑袋压缩”。没有量化目标,压出来的图到底是好是坏、是否达标,全凭感觉。我们在项目启动时先定了一套图片体积预算:
| 图片位置 | 建议预算 | 说明 |
|---|---|---|
| 列表页缩略图 | ≤ 60KB | 搜索结果、商品卡用,尺寸通常 240-400px |
| 商品卡图片 | ≤ 150KB | 首页推荐位、分类页商品卡 |
| 商详页轮播主图 | ≤ 300KB | 商详首屏最重要的视觉区域 |
| 活动 banner | ≤ 200KB | 运营直接投放,容易失控 |
| 长图详情页 | ≤ 800KB | 允许纵向滚动,但要控制整页图片总字节 |
定完预算,再把这些预算写进 Lighthouse CI 或性能监控平台。之后每次压缩、每次图片上线,都拿预算来卡,而不是靠肉眼反复看。
2. 压缩的本质:人眼感知和编码器之间的那点交易
2.1 一张位图是怎么算出来的
先做个简单的数学题。一张 2000×2000 像素的 RGB 图片,在完全没有压缩时,体积是 2000 × 2000 × 3 字节,算下来接近 12MB。如果带有透明通道,也就是 RGBA,那就是 16MB。所以图片能不能压缩、能压多少,取决于你到底能去掉多少“冗余”。
冗余分两种。一种是空间冗余,比如白底商品图的背景是一片纯白,一个像素和周围一万个像素完全一样;另一种是视觉冗余,也就是人眼根本感知不到或者不敏感的细节。有损压缩主要针对的是后者,这也是压缩率能远超 zip 这类无损算法的核心原因。
2.2 有损压缩在“骗”眼睛
现代有损图片编码器做的事情可以简化成四步。
第一步,把 RGB 颜色空间转换成 YCbCr,也就是把亮度信息和色度信息分开。人眼对亮度的敏感度远高于色度,分开以后就可以区别对待。第二步是色度子采样,常见的有 4:4:4、4:2:2、4:2:0。4:4:4 表示色度不降采样,4:2:0 表示色度在水平和垂直方向都只保留 1/4,对文件大小的影响非常明显。第三步是频域变换和量化,比如 JPEG 里的 DCT,把图像从像素域变成频率域,然后把高频细节按照量化表做除法取整。人眼对高频细节的容忍度高,所以这一步可以去大量数据。最后是熵编码,把前面得到的数据流做最终压缩。
用个生活化类比:无损压缩像把一件羽绒服抽真空,压完还是那件羽绒服;有损压缩像是把羽绒服里的绒换成普通棉花,穿上大概暖和,但蓬松度和质感已经变了。压缩器的目标就是换一种“看起来差不多”的东西,把体积降下来。
2.3 JPEG、WebP、AVIF 谁更能打
零售图片优化绕不开格式选型。现在主流的格式有三种:
| 格式 | 相同主观质量下的体积 | 透明通道 | 兼容性 | 主要特点 |
|---|---|---|---|---|
| JPEG | 基准 | 不支持 | 极好 | 老旧但生态成熟,编码速度快 |
| WebP | 约为 JPEG 的 60-75% | 支持 | 很好 | 有损无损都行,解码器普及率高 |
| AVIF | 约为 JPEG 的 40-55% | 支持 | 一般 | 压缩率最高,但编码慢,老系统兼容性有限 |
| JPEG XL | 与 AVIF 接近或更好 | 支持 | 较弱 | 新格式,目前适合实验性使用 |
我个人的经验是:不要意气用事,一上来就全站切 AVIF。AVIF 压缩率高,但编码耗时会明显增加,而且部分低端安卓 WebView 解码 AVIF 反而会出现卡顿。生产环境最稳的组合是“AVIF 优先 + WebP 过渡 + JPEG 兜底”,通过<picture>标签或服务端 Accept 协商来做降级。
2.4 质量参数不是拍脑袋填的
每个编码器都有一堆参数,quality只是其中一个。JPEG 的 quality 控制量化表强度;WebP 除了 quality 还有 effort,控制编码器花多少时间去寻找更优压缩方案;AVIF 有 crf、speed、quantizer 等概念。
一定要理解“质量参数和主观视觉质量不是线性关系”。从 q=90 降到 q=80,文件可能缩小 40%,肉眼几乎看不出区别;从 q=50 降到 q=40,文件也许只缩小 10%,但画质已经开始崩了。所以最佳实践是找自己商品图类型的“拐点”,而不是照搬网上某个标准参数。后面我会讲我们是怎么自动化找这个拐点的。
3. 选型评估:本地工具、图像库还是图片处理服务
3.1 先判断你的图片是“一次性”还是“持续性”
技术选型之前,先回答一个问题:你的图片是历史存量居多,还是每天都会持续上新?
如果只是把一批历史图片压一遍,那根本不需要写复杂系统。Squoosh、cwebp、avifenc 这些工具足够用,手动跑几个命令就行。但零售电商是每天都有新商品、新素材的场景,压缩必须变成一条自动化流水线,而不是一次性的脚本。否则三个月后,线上又会重新堆满大图。
如果图片是在用户请求时才动态生成的,比如用户上传一张头像或店铺装修图,那就得考虑实时处理方案,或者上传时异步转码。商品图不一样,商品上架前是可以接受离线批处理的,所以我们选择预生成。
3.2 常用工具横向对比
我实际用过的工具和方案主要有这些:
| 工具 | 类型 | 优点 | 需要注意的点 |
|---|---|---|---|
| cwebp | Google 官方 CLI | WebP 参数控制细致,稳定 | 只处理 WebP,需要自己写批量脚本 |
| avifenc | AVIF 官方参考编码器 | 压缩率上限高 | 编码慢,参数复杂 |
| libvips | C 库 | 内存占用极低,适合大批量 | 需要学习和封装 |
| sharp | Node.js 的 libvips 绑定 | API 友好,支持 WebP/AVIF | 对 Node 版本有要求 |
| Pillow-SIMD | Python 图像库 | Python 生态好,JPEG 有 SIMD 优化 | AVIF 支持不如 sharp 方便 |
| ImageMagick | 通用 CLI | 格式全,命令广 | 大批量处理时内存占用偏高 |
| 云厂商图片处理 | SaaS | 接入快,有 CDN 配合 | 按量计费,单价需要考虑 |
3.3 为什么最终选了 sharp
我们的技术栈偏 Node.js,而且需要同时输出 WebP 和 AVIF,所以最终选了 sharp。它对 libvips 的封装比较完善,处理大图时内存控制很好,官方文档里给出的性能数据也足够优秀。
没选纯云服务的原因也很简单:我们的商品图本来就存在自建的对象存储里,访问走 CDN。如果引入云图片处理服务,等于把整条链路重新接了一遍,还要考虑每个月的调用费。而自建一条预生成流水线,只是多一个脚本、一个任务队列的事。
如果你团队人力紧张,或者图片访问量不稳定,直接买云服务其实更划算。判断标准就一条:算一下一个月要转码多少张图,云服务费用是否超过一个初级工程师一天的人工成本。图片量小,云服务;图片量大且规则复杂,自建。
4. 实操:构建一条商品图智能压缩流水线
4.1 “智能”到底体现在哪里
所谓智能压缩,不是简单地对所有图片跑同一个 quality。商品图内容差异很大,白底图、模特图、皮革纹理图、透明 PNG,它们对压缩参数的敏感度完全不同。
我们做的第一件事是提取图片特征,主要看三个维度:
- 尺寸:超过目标尺寸多少倍,先缩后压。
- 透明通道:有 alpha 的图不能输出 JPEG。
- 复杂度:用通道标准差做了一个粗略估计。背景纯白、物品居中的白底图标准差很低,复杂纹理或密集商品的图标准差高。
根据这三个维度,我们设定了几套策略:低复杂度图用低质量、高压缩;高复杂度图提高 quality,防止边缘发糊;带透明通道的图走 WebP 或 PNG;需要保留文字的类目图,比如电子产品参数图,则使用近无损模式。
4.2 核心代码实现
下面这段代码是我们流水线的简化版,用 sharp 完成多尺寸输出和格式转换。
const sharp = require('sharp'); const fs = require('fs'); const path = require('path'); async function optimizeProductImage(input, outputDir) { const metadata = await sharp(input).metadata(); const stats = await sharp(input).stats(); // 取通道标准差的峰值,粗略判断图像复杂度 const complexity = Math.max(...stats.channels.map((c) => c.stdev)); const baseOptions = { quality: 78, effort: 4 }; if (complexity > 60) baseOptions.quality = 84; if (complexity < 35) baseOptions.quality = 72; const sizes = [ { name: 'thumb', width: 240 }, { name: 'list', width: 600 }, { name: 'detail', width: 1200 }, ]; for (const size of sizes) { await sharp(input) .resize(size.width, null, { withoutEnlargement: true }) .webp(baseOptions) .toFile(path.join(outputDir, `${size.name}.webp`)); // 透明图不输出 AVIF,避免兼容性问题 if (!metadata.hasAlpha) { await sharp(input) .resize(size.width, null, { withoutEnlargement: true }) .avif({ quality: Math.round(baseOptions.quality * 0.85), effort: 5, }) .toFile(path.join(outputDir, `${size.name}.avif`)); } } } async function batchRun(inputDir, outputDir) { const files = fs.readdirSync(inputDir).filter((f) => /\.(jpe?g|png)$/i.test(f)); for (const file of files) { const src = path.join(inputDir, file); const name = path.parse(file).name; const out = path.join(outputDir, name); fs.mkdirSync(out, { recursive: true }); await optimizeProductImage(src, out); console.log(`done: ${file}`); } }这段代码有几个关键点:
第一,stats.channels返回的是各个通道的均值和标准差,标准差大说明图像的边缘和纹理丰富,需要更高 quality。第二,withoutEnlargement: true很重要,避免 300px 的小图被强行放大成 1200px。第三,AVIF 的 quality 比 WebP 低 10-15 个百分点是正常的,因为两者算法不同,不能拿同一个数去套。
4.3 命令行兜底方案
有时候运营发来一张图,不想走整套流水线,命令行处理是最快的:
cwebp -q 80 -m 6 -pass 10 -metadata none input.jpg -o output.webp avifenc input.jpg output.avif -q 40 -s 4 --depth 10 -y 420-m 6是告诉 cwebp 用更慢但压缩率更高的模式,-pass 10是让编码器多轮优化。AVIFENC 的-s 4是速度档位,数字越大越快但压缩率越低,-y 420是启用 4:2:0 色度子采样。需要注意的是,命令行工具适合手工处理,不适合大批量,因为单张耗时不可控。
4.4 质量校验和回归测试
压缩完不能直接上线,必须加一道质量校验。我们的做法是维护一个“黄金图片集”,包含典型的白底图、模特图、透明 PNG、复杂纹理图。每改动一次压缩参数,就重新跑一遍黄金集,用compare -metric SSIM对比处理前后的结构相似度,低于阈值就自动报错。
另外,自动化流水线里一定要加体积断言:处理后的图如果比原图还大,说明原图本身是伪装的 PDF 或者已经压过的图,需要告警而不是默默通过。
4.5 和前端/CDN 的衔接
后端产出多格式文件后,前端用<picture>标签做优雅降级:
<picture> <source type="image/avif" srcset="/img/1001/detail.avif"> <source type="image/webp" srcset="/img/1001/detail.webp"> <img src="/img/1001/detail.jpg" loading="lazy" decoding="async" width="1200" height="1200" alt="商品名称" > </picture>width和height一定要写,否则图片加载完页面会发生布局偏移,CLS 指标会很难看。loading="lazy"让非首屏图片延迟加载,decoding="async"避免图片解码阻塞主线程。
5. 上线后的量化收益:从 Lighthouse 到商详页真实耗时
5.1 用什么指标衡量
我们的衡量体系分成两层:实验室内 Lighthouse 数据,以及真实用户环境的 RUM 数据。
Lighthouse 侧重可复现的基准测试,适合在发版前做横向对比。RUM 数据则反映了真实网络、真实设备下的表现。我建议重点盯四个指标:FCP、LCP、CLS,以及页面总传输字节数。图片优化最直接的影响就是 LCP,因为它往往是最大图片元素。
5.2 我们这批商品图的实测结果
以下是一批 20 张典型商品图在改造前后的平均数据:
| 场景 | 原图平均体积 | WebP 处理后 | AVIF 处理后 | LCP 变化 |
|---|---|---|---|---|
| 首页商品卡 | 1.8MB | 310KB | 220KB | 4.2s -> 2.6s |
| 搜索列表图 | 2.4MB | 380KB | 260KB | 首屏请求字节减少 76% |
| 商详轮播图 | 8.5MB(5张) | 1.4MB(5张) | 1.0MB(5张) | 3.8s -> 2.1s |
必须说明,这是在我们设定的质量参数下测得的数据。如果你把 WebP 的 quality 调到 95,体积不会下降这么多。我们把“肉眼不可见差异”作为参数选择的基准,而不是追求极限压缩率。
5.3 业务指标的观察
图片变小之后,最直观的感受是弱网测试环境不再卡成 PPT。我们做了一次持续一周的灰度实验,控制其他版本变动,只切换图片处理逻辑。实验组弱网用户的跳出率明显下降,平均浏览页数略有上升。
但我不会直接把转化率提升归因到图片优化上。电商业务的转化率受价格、库存、运营活动影响很大,一次改版里通常混着多个变量。图片优化的价值更多体现在“体验成本”的降低,搜索页、分类页这些高 PV 页面的用户能更早看到商品内容,这是确凿的收益。
6. 容易被忽略的四个后期坑:缓存、源图质量、重复压缩和 CDN 回源
6.1 缓存策略与版本号
图片压缩完成后,最容易被忽视的是缓存策略。如果图片 URL 没变,CDN 和浏览器都会继续给用户旧图,压缩成果要等缓存过期才能生效。
我们的做法是给图片 URL 加上内容哈希,压缩参数变更后,文件名跟着变化,从根源上避免缓存穿透。同时 CDN 上图片缓存时间设置得很长,比如 30 天,因为预生成图片已经不可变了。如果你使用了带 Accept 协商的动态格式,还要特别注意 CDN 是否会把Vary: Accept纳入缓存 key,否则就会出现 Chrome 拿到 AVIF、老浏览器也拿到 AVIF 的兼容性问题。
6.2 源图质量决定一切
这条经验是用事故换来的。有一批商品图,我们压缩后发现文字边缘全是锯齿,怎么调参数都没用。最后排查发现,运营上传的所谓“原图”已经是别人压缩过两轮的 JPG,质量参数只剩 55。在垃圾源图上做优化,等于从井里提水,水是脏的,过滤器再强也没用。
正确的做法是:在存储层保留一份最优质量的 master 副本,所有派生图一律从 master 生成。master 可以是高质量 JPEG、PNG 或 TIFF,它不直接面向用户,只作为压缩流水线的输入。有了 master,之后想调整格式、尺寸、压缩算法,都能随时重新生成。
6.3 预生成 vs 实时处理
预生成适合 SKU 可控的商品图,但有些场景必须实时处理,比如用户上传的图片、运营临时创建的营销素材。实时转码的代价是 CPU,一张 2MB 的图在服务端转成 WebP 需要几十到几百毫秒,高峰期会吃掉大量算力。
我们的折中方案是:热销商品图预生成,长尾图片和实时上传图走异步任务队列,先生成一个 600px 的临时缩略图,再在后台慢慢生成完整规格。这样用户不会因为转码排队而白等。
6.4 兼容性回退与客户端解码开销
AVIF 不是所有浏览器都能解,iOS 14 以下基本无缘。我们线上实际跑下来,还有一类低端安卓手机,虽然 WebView 支持 AVIF,但解码一个 1200px 的 AVIF 图需要 100-200ms,比解码同样尺寸的 WebP 慢不少。图片文件是小了,用户等待时间反而没降。
所以现在我们对低端机策略是:根据Accept头里的格式偏好给客户端返回合理格式,同时保留 JPEG/WebP 作为降级。不要在兼容性上赌,零售用户的设备千奇百怪,稳定比技术新潮重要。
6.5 最后一条经验:先缩尺寸,再谈压缩
压缩做得再狠,也不如 resize 一刀来得有效。4000px 的摄影大图,哪怕压到 200KB,扔到 300px 的列表缩略图里,依然是浪费流量。也就是说,图片优化的第一原则永远是“匹配容器尺寸”,第二原则才是压缩算法。我们的流水线里,resize 永远在编码之前,并且每个输出规格都有明确的宽度上限。经过这轮实践,我最大的感受是:图片优化不是做一个一次性项目,而是要把“先分析、再编码、后校验”变成一条自动运转的管道。只要 master 源图和管理规范在,后续换编码器、调参数、加新规格,都只是在这条管道上加一段逻辑而已。