前端 WebP 图片使用入门:让图片更小,页面更快

上 WebP,同一张商品图转过去常常能省下三四成体积,这一步本身没什么好犹豫的。

真正要算清楚的是这份收益后面挂着的成本:你得为不支持的浏览器准备一份退路、得处理好 CDN 的缓存协商别让老设备拿到错格式、还得顺带把响应式尺寸一起做了,否则光换格式的收益会被"移动端仍在下载 2000px 大图"抵消掉一大半。

这笔账我算错过一次。早年做图片优化,我只盯着格式转换,把一批 JPG 转成 WebP,体积报表很好看,可移动端加载还是慢。查下来才发现,页面在小屏上依然加载的是那张 2000px 宽的原图,格式省下来的那点体积,跟尺寸浪费掉的比起来不值一提。那次之后我才把图片优化拆成三层独立的账来算:格式、尺寸、加载时机,少算一层,体积优化的收益都会打折。

WebP 省了多少,代价挂在哪

WebP 是 Google 推的图片格式,卖点很直接:观感接近的前提下,文件通常比 JPG、PNG 更小。对前端意味着页面加载更快、带宽和流量占用更低、图多的页面更好优化、CDN 缓存成本也可能降下来。电商、博客、资讯、后台封面这些场景用得很多。

但它不是无脑替换所有格式的银弹。照片、插画、透明图、动图的最优策略并不一样,选格式前得先看图片内容——WebP 到底省多少、AVIF 值不值得再叠一层,下面会拿真实图算一遍。

而只放 WebP 要担的第一份成本,就是兼容。桌面 Chrome 早就支持了,但真实项目里绕不开这几类环境:企业内网的旧浏览器、老设备、App 内嵌的 WebView。这些地方如果只给一个 WebP 地址,图片可能直接不显示。上线前必须确认目标用户实际用的是什么浏览器,而不是拿开发机的 Chrome 当全世界。

到底省多少:拿真实图跑一遍再决定

"WebP 更小"是句正确但太笼统的话。省多少、值不值得为它多担一份兼容成本,得看真实图。我的习惯是上线前拿一批代表性图片跑个对比,别凭感觉。命令行就能批量出数:

1# 用 cwebp 转一张看体积
2cwebp -q 78 cover.jpg -o cover.webp
3ls -l cover.jpg cover.webp

跑下来能看到差距很分场景:照片类、颜色过渡丰富的图,WebP 常能省三四成;但本来就很小的图标、纯色块、已经压得很狠的 JPG,转成 WebP 未必更小,有时甚至更大。这时候的判断是:省下来的体积如果还抵不上多维护一套格式和退路的成本,那这张图就别转,保持原格式反而干净。

AVIF 也是这两年绕不开的选项,很多照片场景压缩率比 WebP 还好一截。但它的账要单独算:编码更慢、构建期转的耗时明显更长,兼容面比 WebP 窄,某些图上的画质表现也要实测。我的做法是让 picture 按 AVIF、WebP、JPG 的顺序排 source,浏览器从上往下挑第一个支持的,等于给愿意付兼容成本的场景自动吃上 AVIF 的红利,又不牺牲老设备:

1<picture>
2  <source srcset="/cover.avif" type="image/avif">
3  <source srcset="/cover.webp" type="image/webp">
4  <img src="/cover.jpg" alt="封面">
5</picture>

多一层 AVIF,产物就多一份、构建就慢一点,这份代价要不要付,取决于这批图对体积有多敏感。关键业务图别只按格式流行度选,先拿真实图跑一组对比,盯着体积、清晰度、边缘和颜色过渡看,再决定上到哪一层。

兼容的成本:备用格式与缓存协商

守兼容的第一层是准备一份备用格式:优先给 WebP,同时留一张 JPG 或 PNG。第二层的成本更隐蔽,藏在 CDN 的自动协商里。

如果 CDN 支持按请求头自动返回 WebP/AVIF,业务代码确实清爽了——不用到处拼不同格式的地址。但这里有个必须付的代价:缓存 key 要包含格式维度,否则会出事故。

关键在 Vary: Accept。浏览器请求图片时会在 Accept 头里声明自己支持什么,比如支不支持 image/avifimage/webp。CDN 根据 Accept 返回不同格式,却没把 Accept 放进缓存维度,事故就来了:第一批支持 WebP 的用户把 WebP 响应缓存进了边缘节点,后面一个不支持 WebP 的老 WebView 命中同一份缓存,图直接挂。

理想响应头大概是这样:

1content-type: image/webp
2cache-control: public, max-age=31536000, immutable
3vary: Accept

如果 CDN 平台不方便按 Accept 分缓存,那就别硬做透明协商,改成显式 URL,比如 /cover.jpg?format=webp 或者干脆 /cover.webp。显式 URL 写起来啰嗦,但缓存行为完全可控,是拿一点开发成本换掉一类线上事故,这买卖划算。

转换这一步的工程成本:放构建期还是运行期

WebP 图从哪来,也是笔要算的账。手动一张张导出 WebP 在图片量小的时候还行,量一大就得自动化,这时候有两条路:交给 CDN 运行期转,或者放进构建期批量转。

CDN 转的好处是业务里存一份原图就行,格式由请求触发;代价是前面说的缓存协商,以及每张图第一次访问时的转码延迟。构建期转的好处是产物确定、上线即最优,代价是构建时间变长、仓库或产物里多出一批派生文件。图片相对固定的站点我更倾向构建期转,用 sharp 这类库写个脚本就能批量出多格式多尺寸:

1const sharp = require('sharp')
2
3async function build(input, base) {
4  const widths = [400, 800, 1600]
5  for (const w of widths) {
6    await sharp(input).resize(w).webp({ quality: 78 }).toFile(`${base}-${w}.webp`)
7    await sharp(input).resize(w).jpeg({ quality: 82 }).toFile(`${base}-${w}.jpg`)
8  }
9}

一张原图跑出 banner-400.webpbanner-800.webpbanner-1600.webp 和对应 JPG 备用图,正好喂给后面的 srcset。判断标准是:图片变化频繁、用户上传为主的走 CDN 运行期转;图片相对静态、追求确定性的走构建期转。别两头都不占——既不做转换、又指望它自动变小,是不存在的。

用 picture 把回退选择交给浏览器

代码层面守兼容,成本最低的写法是 picture。它让浏览器自己在几种格式里挑能支持的那一个,你不用写任何能力判断:

1<picture>
2  <source srcset="/images/banner.webp" type="image/webp">
3  <source srcset="/images/banner.jpg" type="image/jpeg">
4  <img src="/images/banner.jpg" alt="活动封面">
5</picture>

逻辑很清楚:支持 WebP 就加载 banner.webp,不支持就退回 banner.jpg,最后的 img 是兜底内容,连 picture 都不认识的老浏览器也能显示它。这套写法的好处是把"要不要回退、回退到哪"这个判断从你的 JS 里移交给了浏览器,可维护性高很多。

有个细节要记牢:浏览器是从上到下取第一个自己认识、且 type 支持的 source,命中就停,不会再往下比。所以 source 的顺序就是优先级,越"先进"的格式放越上面,AVIF 在 WebP 之前、WebP 在 JPG 之前。另外 img 不是可选的摆设,它既是最后一道保底,也是 altwidthheight 这些属性的实际落点——很多人只往 source 上堆格式,却忘了给 img 写宽高,结果 CLS 还是没稳住。picture 只解决"选哪个格式",尺寸和可访问性仍然挂在里面那个 img 上。

尺寸这层账:srcset 和 sizes

前面说过,只换格式不改尺寸,收益会被抵消。所以响应式尺寸得和格式一起做——移动端只要 375px 宽的图,却下 2000px 的大图,就算是 WebP 也在浪费流量。

picturesrcset 能把这两层一起优化:

1<picture>
2  <source
3    type="image/webp"
4    srcset="/images/card-400.webp 400w, /images/card-800.webp 800w"
5    sizes="(max-width: 600px) 400px, 800px"
6  >
7  <img
8    src="/images/card-800.jpg"
9    srcset="/images/card-400.jpg 400w, /images/card-800.jpg 800w"
10    sizes="(max-width: 600px) 400px, 800px"
11    alt="文章封面"
12  >
13</picture>

浏览器会结合屏幕宽度和像素密度挑更合适的那张。这里要付的成本是 sizes 得写对——它描述的是图片最终在页面里的展示宽度,不是原图宽度。写大了浏览器选过大的资源、白费流量;写小了高 DPR 屏上就糊。图片策略和布局是绑在一起的,改了布局就得同步核对 sizes

还有个容易踩的点是 sizes 和实际 CSS 布局脱节。sizes 是你手写的一个"承诺",告诉浏览器这张图大概会以多宽显示,但它并不会去读你的 CSS。一旦布局改了、断点动了,sizes 没跟着改,浏览器就会按过时的承诺选图——要么选大了浪费,要么选小了发糊。所以每次动图片相关的布局,都得回头核对一遍 sizes,这是响应式图片省不掉的一份维护成本。

尺寸选没选对,不用猜,直接看浏览器实际加载了哪张:

1const img = document.querySelector('img')
2console.log(img.currentSrc)
3console.log(img.naturalWidth, img.clientWidth, window.devicePixelRatio)

currentSrc 是最终加载的 URL,naturalWidth 是图片真实像素宽度,clientWidth * devicePixelRatio 大致是当前屏幕需要的像素宽。如果 clientWidth 只有 300、DPR 是 2,却加载了 1600 宽的图,说明 sizes 或 CDN 裁剪偏大了;反过来需要 600 像素却只给 300,高清屏上就糊。

脚本拼地址时,才动手判断 WebP

有些场景图片地址不是写死在 HTML 里,而是 JS 动态拼的。这时候没有 picture 替你处理回退,只能手动测一下浏览器能不能加载 WebP:

1function checkWebpSupport() {
2  return new Promise((resolve) => {
3    const img = new Image();
4
5    img.onload = function () {
6      resolve(img.width > 0 && img.height > 0);
7    };
8
9    img.onerror = function () {
10      resolve(false);
11    };
12
13    img.src =
14      "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA";
15  });
16}

判断结果缓存起来,别每次都重测。但要记住主次:能用 picture 的地方优先用 HTML 原生能力,手写判断只留给图片地址由脚本拼接、组件库封装或特殊运行环境这类真绕不开的场景。多引一段运行时判断逻辑,就是多一份要维护的成本。

这段检测还有个隐藏代价:它是异步的。Imageonload/onerror 要等一个事件循环才回来,如果你在拿到结果前就急着渲染图片,第一帧可能用错格式或者干脆是空的。所以脚本拼图这条路,天然要接受一次"先占位、检测完再替换"的过程,不像 picture 那样浏览器一步到位。这也是我尽量把格式选择留在 HTML 里、少走 JS 检测的原因——省下的不只是代码,还有这层异步带来的时序麻烦。

压缩质量:省体积的另一面代价

WebP 的目标从来不是把文件压到越小越好,而是在体积和画质之间找平衡。压过头,图片会出现明显块状、颜色断层、边缘发糊。对商品图、头像、作品集这类要看清细节的图,过度压缩会直接影响用户判断——省下来的那几 KB,换来的是转化率的损失,这账就亏了。

WebP 本身还分有损和无损两种模式,别混着用。有损适合照片、连续色调的图,同样质量下体积最省;无损适合图标、UI 切图、需要像素级精确的图,压缩率不如有损但不会引入任何画质损失。用错模式的典型症状是:把一张纯色 UI 图走了有损,边缘出现肉眼可见的杂色;或者把一张大照片走了无损,体积压根没降下来。质量参数也不是越低越好,我的经验值是照片类 quality 落在 75 到 82 之间,大多数图既看不出损失、体积又降得明显,再往下掉才开始明显糊。

我一般这么分:照片类走有损 WebP;图标、透明图看情况用 PNG、SVG 或无损 WebP;关键视觉图保留更高质量;缩略图可以压得狠一点;转换后一定人工抽查。抽查时我固定看几类图——人像、商品细节、浅色渐变、带文字的海报、透明图,因为不同内容对压缩的敏感点不一样。带文字的图尤其要盯,稍微激进一点字就糊,光看平均压缩率是发现不了的。

透明图和动图:WebP 的另一面收益

WebP 有个常被忽略的能力:它同时支持透明通道和动画,等于能一格式吃掉 PNG 和 GIF 两个老格式的场景。

透明图这块,过去要 alpha 通道就得用 PNG,而 PNG 在颜色丰富的半透明图上体积很吓人。WebP 的有损模式也能带透明通道,同样一张带阴影的商品抠图,WebP 常常只有 PNG 的几分之一。判断点在于:纯图标、线条简单、要求无损锐利的,SVG 或 PNG 仍然更合适;照片型的透明图(带柔和边缘、渐变阴影的那种),WebP 收益最明显。

动图更值得换。GIF 只有 256 色、体积又大,一个稍长的动图动辄几 MB。同样的动画换成动画 WebP,体积能砍掉一大截,色彩还更好。代价还是那两个字——兼容和转换:动画 WebP 的转换工具链比静态图麻烦些,老环境不支持时得退回 GIF 兜底。所以我的判断是:页面里那些又大又拖慢加载的 GIF,是最该优先换成 WebP 的目标,静态小图反倒不急。真要追求极致,短动图也可以考虑直接用静音自动播放的 <video> 配 WebM,体积比动画 WebP 还小,但那就是另一套加载和交互成本了。

加载时机:懒加载与首屏图的反向判断

第三层账是加载时机。首屏之外的图可以懒加载,把带宽让给用户当下要看的内容:

1<img src="/images/post-cover.webp" loading="lazy" alt="文章封面">

图片容器最好提前定好宽高或比例,避免加载完成后页面跳动,也就是控制 CLS:

1.cover {
2  aspect-ratio: 16 / 9;
3  width: 100%;
4  object-fit: cover;
5}

但这里有个反向判断得注意:如果图片是 LCP 元素,比如首页 hero 图或文章首图,就绝不能懒加载,反而要尽早加载,必要时用 fetchpriority="high" 或框架图片组件的优先级设置往前提。首屏大图和列表长尾图的策略应该是相反的,一刀切懒加载会把最重要那张图的加载往后拖。

首屏大图还得避开"CSS 背景图一把梭"。背景图享受不到 altsrcsetsizes,也不容易被浏览器判定为 LCP 优先资源。真正承载内容的主图,我更愿意用 <img><picture>,再用 object-fit 控裁剪:

1<picture>
2  <source srcset="/hero-800.webp 800w, /hero-1600.webp 1600w" type="image/webp">
3  <img
4    src="/hero-1600.jpg"
5    srcset="/hero-800.jpg 800w, /hero-1600.jpg 1600w"
6    sizes="100vw"
7    fetchpriority="high"
8    width="1600"
9    height="900"
10    alt="产品主视觉"
11  >
12</picture>

纯装饰图用空 alt="";承载内容的图就把替代文本写清楚。写死的 widthheight 也别省,它让浏览器在图加载前就能预留出正确比例的空间,是稳定 CLS 的关键一环。

一个容易被漏掉的成本:解码

体积只是加载环节的账,图片进了内存还有一步——解码。大图在主线程上解码会造成短暂卡顿,尤其是一次性插入很多张大图的列表页,滚动时能明显感觉到掉帧。这跟格式关系不大,WebP 也一样要解码。

浏览器给了一个提示属性 decoding,让图片异步解码、别阻塞渲染:

1<img src="/card.webp" decoding="async" width="400" height="300" alt="卡片">

对确实需要"解好再显示、避免闪一下"的关键图,还能用 Image.decode() 显式等解码完成再插入:

1const img = new Image()
2img.src = url
3await img.decode()
4container.appendChild(img) // 此时插入不会引起解码卡顿

这里的判断是:首屏关键图求稳,可以显式 decode 再上屏;长列表里的大量图片求流畅,交给 decoding="async" 让浏览器错峰解码。图片优化到后面,省的就不只是网络体积,还有主线程的时间。

首屏图有没有拖慢 LCP,直接量

首屏大图最该盯的指标是 LCP,也就是最大内容绘制。前面把 hero 图设成 fetchpriority="high"、不懒加载,都是为了让它尽早开始下载。做没做到位不用猜,用 PerformanceObserver 直接量出来是哪张图当了 LCP、什么时候完成的:

1new PerformanceObserver((list) => {
2  const entry = list.getEntries().at(-1)
3  console.log('LCP element:', entry.element)
4  console.log('LCP time:', entry.startTime)
5}).observe({ type: 'largest-contentful-paint', buffered: true })

如果打印出来的 LCP 元素不是你以为的那张主图,或者时间偏晚,往往就是优先级没提上去、或者图被排在了一堆阻塞资源后面。首屏图的优化是"下得早"比"压得小"更要紧——一张压得再小的图,排队排在后面也救不了 LCP。

把三层账一起算

现在做图片优化,我不会再只盯"是不是 WebP"。先看图片是不是按真实展示尺寸输出,再看格式和压缩质量,接着分清首屏图和非首屏图的加载策略,最后才是 CDN 转码、缓存协商和兼容层的退路。WebP 很有用,但它只是这几层里的一环。

真到要拍板一张图用什么格式,我脑子里过的顺序大概是这样:先看它是不是图标或矢量内容,是就走 SVG,跟位图格式没关系;不是的话看要不要透明、要不要动,透明和动图优先考虑 WebP 顶掉 PNG、GIF;剩下的照片型内容,默认 WebP 加 JPG 垫底,对体积特别敏感的关键图再叠一层 AVIF。这个顺序不复杂,但它把"格式"从一个笼统的选择拆成了几个能各自判断的岔路,比一律"转 WebP"要靠谱。

图越多的页面,越要把这几件事绑在一起做:WebP 优先、JPG/PNG 兜底、响应式尺寸、合理压缩质量、懒加载、异步解码、固定宽高比例、目标浏览器验证。体积收益和这些工程成本是一体两面的——只想要前者、不肯付后者,最终省下的带宽会从别处的事故和体验里加倍还回来。