构建期图片自动化实战:多尺寸生成、WebP/AVIF转换与自动srcset
图片一直是前端体积的大头。一个稍微像样的落地页,首屏可能就压着两三张两三兆的原图,而这些原图往往是设计师直接从相机或设计软件里导出来的,既没压缩,也只有一个尺寸。手机上加载一张为 27 寸显示器准备的 4000px 大图,纯属浪费流量。处理图片这件事,时间点的选择比工具的选择更重要。这篇文章想聊清楚一个我自己踩了不少坑才想明白的问题:为什么图片的多尺寸生成、格式转换和 srcset 这套活儿,最好放在构建期做,以及具体怎么用 sharp 和 vite-imagetools 把它做扎实。文章末尾也会老实交代这套方案不适用的场景,别把它当银弹。为什么是构建期,而不是运行时或手工处理图片,理论上有三个时间点可以下手。手工处理,就是设计师或前端在本地用工具压好、切好几个尺寸,再手动写进代码。这个方案的问题不是做不出来,而是不可持续。加一张图要手动切五个尺寸转两种格式,写一长串 srcset,人会烦,会漏,会忘。图一多,质量就崩。运行时处理,是很多图片 CDN 和图床服务的做法:请求image.jpg?w800formatwebp时服务端现场裁剪转码。这个方案很灵活,适合用户上传内容(UGC)这种编译期根本拿不到图的场景。但它有两个代价:第一次请求某个尺寸时要现算,首次访问会慢;而且你要么自己维护一套转码服务,要么按量付费给第三方。对于站点里那些编译时就已经确定、而且基本不变的图片——logo、插画、文章配图、产品截图——每次请求都现算就是浪费。构建期处理,是在build的时候一次性把所有静态图片压缩、生成多尺寸、转好格式、把 srcset 写好。它的核心优势是:算一次,之后全是纯静态文件,直接进 CDN,请求命中就是命中,没有任何运行时开销;产物内容确定,可以指纹化(hash 文件名)做永久强缓存;构建失败能在 CI 里拦住,不会把没处理好的图带到线上。一句话总结取舍:内容在构建时已知且稳定,就放构建期;内容来自用户运行时上传,才用运行时方案。大部分官网、博客、文档站、营销页的图片,都属于前者。手写一个 sharp 脚本:先把原理跑通在引入任何框架插件之前,我建议先用 sharp 手写一遍脚本。sharp 底层是 libvips,是 Node 生态里图片处理的事实标准,性能和内存都比 ImageMagick 系好一大截。手写一遍,你才知道插件背后到底在干什么。下面这个脚本做三件事:遍历原图目录、生成一组宽度、每个宽度输出 WebP 和 AVIF 两种格式。// scripts/gen-images.mjs// 运行: node scripts/gen-images.mjsimport{readdir,mkdir}fromnode:fs/promises;import{join,parse}fromnode:path;importsharpfromsharp;constSRC_DIRassets/images-raw;// 原图目录constOUT_DIRpublic/img;// 产物目录constWIDTHS[320,640,960,1280,1920];// 要生成的宽度档位// 每种格式的编码参数。AVIF 压得更狠但编码慢,quality 可以比 WebP 低一档constFORMATS[{ext:webp,options:{quality:78}},{ext:avif,options:{quality:55}},];asyncfunctionprocessOne(fileName){const{name}parse(fileName);// 去掉扩展名的纯文件名constinputjoin(SRC_DIR,fileName);constmetaawaitsharp(input).metadata();// 先读原图尺寸for(constwidthofWIDTHS){// 关键:绝不放大。原图比目标档位窄就跳过,避免生成模糊的伪高清if(meta.widthwidthmeta.width)continue;for(const{ext,options}ofFORMATS){constoutjoin(OUT_DIR,${name}-${width}.${ext});awaitsharp(input).resize({width})// 只给宽,高按比例自适应.toFormat(ext,options).toFile(out);}}}asyncfunctionmain(){awaitmkdir(OUT_DIR,{recursive:true});constfilesawaitreaddir(SRC_DIR);// 逐张串行处理即可。sharp 内部已经用了 libvips 的多线程,// 手动 Promise.all 并发反而容易把内存顶爆,得不偿失for(constfoffiles){if(/\.(jpe?g|png)$/i.test(f)){awaitprocessOne(f);console.log(done:${f});}}}main().catch((e){console.error(e);process.exit(1);// 让 CI 能感知失败});跑完你会在public/img下得到hero-320.webp、hero-320.avif、hero-640.webp……一整组文件。这里有几个容易被忽略的点值得展开。不放大是铁律。上面width meta.width那行的判断很关键。如果原图只有 1000px 宽,你还硬生成 1920px 的版本,得到的只是一张被拉糊的图,体积白白变大,画质还更差。宁可少一档,也不要伪高清。AVIF 编码很慢。同一张图,AVIF 的编码时间可能是 WebP 的好几倍。它压缩率确实更高(同画质下往往比 WebP 再小 20% 上下),但这个慢会实打实地累积到构建时间上,后面讲增量缓存时还会回到这个问题。质量参数要按格式分别调。WebP 的 quality 78 和 AVIF 的 quality 55 在观感上大致相当,但 AVIF 在低数值下依然能保持不错的画质。直接给两种格式设同一个 quality 是常见的误配。自动 srcset:让浏览器自己挑图生成好了,只是完成了一半。真正省流量的是让浏览器根据设备情况挑合适的那一张,这就要靠srcsetsizes和picture。手写这段 HTML 是很痛苦的,一张图要写十几行。所以第二步是让脚本顺便把 HTML 片段也吐出来。核心是这样一段结构:picture!-- 优先给 AVIF,浏览器不支持会自动往下找 --sourcetypeimage/avifsrcset/img/hero-320.avif 320w, /img/hero-640.avif 640w, /img/hero-960.avif 960w, /img/hero-1280.avif 1280wsizes(max-width: 640px) 100vw, 640px/sourcetypeimage/webpsrcset/img/hero-320.webp 320w, /img/hero-640.webp 640w, /img/hero-960.webp 960w, /img/hero-1280.webp 1280wsizes(max-width: 640px) 100vw, 640px/!-- 最后兜底一张原格式,老浏览器也能看 --imgsrc/img/hero-960.webpwidth960height540alt示意图loadinglazydecodingasync//picture这里有两个反复被人搞错的地方。srcset里的320w是图片的真实像素宽度,不是显示宽度。它告诉浏览器这张图有多少像素,浏览器会结合sizes和设备像素比(DPR)自己算该下哪张。sizes才是这张图在页面上实际会占多宽。(max-width: 640px) 100vw, 640px的意思是视口窄于 640px 时图占满整宽,否则固定 640px 宽。sizes写错,浏览器的挑选逻辑就全乱了——这是自动 srcset 里最容易翻车的一环,务必让它和 CSS 里的真实布局对上。另外那个img上的width和height属性别省。它们让浏览器在图片下载完之前就能算出占位空间,避免加载时页面元素跳动(CLS)。这个尺寸也可以由脚本从 sharp 的 metadata 里读出来自动填。用 vite-imagetools 把这套流程收编进构建手写脚本能跑通原理,但真到工程里,你不想每加一张图就手动维护那一长串 srcset。在 Vite 项目里,vite-imagetools 把上面这一整套(多尺寸、多格式、srcset 字符串)收进了 import 语句。安装后配置很简单:// vite.config.tsimport{defineConfig}fromvite;import{imagetools}fromvite-imagetools;exportdefaultdefineConfig({plugins:[imagetools({// 给没带参数的 import 一组默认指令,避免每处都重复写defaultDirectives:(url){// 只对标了 ?presethero 的图套用这组默认if(url.searchParams.get(preset)hero){returnnewURLSearchParams({format:avif;webp,// 分号分隔即多格式w:320;640;960;1280;1920,// 分号分隔即多尺寸as:picture,// 直接产出 picture 需要的结构});}returnnewURLSearchParams();},}),],});然后在代码里,一行 import 就拿到所有产物的元数据:// 查询参数直接声明你要什么:五个宽度、两种格式、picture 结构importheroPicfrom./assets/hero.jpg?presethero;// heroPic 是一个对象,形如:// { sources: { avif: ...srcset..., webp: ...srcset... }, img: { src, w, h } }// React 里直接摊开用functionHero(){return(picturesource typeimage/avifsrcSet{heroPic.sources.avif}/source typeimage/webpsrcSet{heroPic.sources.webp}/img src{heroPic.img.src}width{heroPic.img.w}height{heroPic.img.h}alt首屏示意loadinglazydecodingasync//picture);}好处很直接:图片进了模块图谱。Vite 会自动给产物加内容 hash 做长缓存,构建时若图片缺失会直接报错而不是等上线才发现,srcset 字符串也全自动拼好,不用人肉维护。你要做的只是把查询参数声明清楚我要什么。框架侧还有几个等价方案值得知道:Next.js 的next/image组件走的是运行时/按需生成再缓存的路子,理念略有不同但目标一致;Astro 的Image和astro:assets是纯构建期,和这里讲的最贴近;Nuxt 有nuxt/image。选型时看你项目已经在用什么栈,别为了一个插件换框架。缓存与增量:别让构建越来越慢上面所有方案都绕不开一个现实问题:图片处理很慢,尤其是 AVIF。一个站点几十上百张图,每张切五个尺寸转两种格式,全量跑一遍几分钟起步。如果每次build都从头处理所有图,本地开发和 CI 都会被拖垮。解决办法是增量与缓存,思路就一条:图片的原图内容没变,产物就不该重新生成。vite-imagetools 这类插件在开发模式下自身带缓存。而对手写脚本,最实用的做法是给原图算内容 hash,把 hash 存进一个 manifest,下次跑先比对:// 增量判断的核心:内容 hash 没变就跳过import{createHash}fromnode:crypto;import{readFile,writeFile}fromnode:fs/promises;asyncfunctionhashFile(path){constbufawaitreadFile(path);returncreateHash(sha256).update(buf).digest(hex).slice(0,16);}// 处理前读上次的 manifest(一个 { 文件名: hash } 的 JSON)// 若当前 hash 记录里的 hash,且产物文件都还在,就 continue 跳过// 处理完把新 hash 写回 manifest在 CI 里,还要把产物目录和这个 manifest 一起缓存到 CI 的 cache 里(GitHub Actions 的actions/cache、GitLab CI 的cache:都行),key 用图片源目录的 hash。这样只有真正改动或新增了图片的那次构建才会重新处理,其余全部命中缓存,构建时间就稳住了。配合 CDN 时还有一层要点:构建产物用了内容 hash 命名(如hero.a1b2c3.webp),就可以在 CDN 上放心开Cache-Control: max-age31536000, immutable永久缓存。图片内容一变,hash 变,文件名变,URL 变,缓存自然失效,永远不会有图更新了但用户还看到旧图的问题。构建期方案和不可变缓存是天生一对。诚实的边界:这套方案什么时候不该用写到这里都是好话,但任何方案都有它的适用边界,不交代清楚就是耍流氓。图片来自用户运行时上传,这套不适用。构建期能处理的前提是构建时图片已经在仓库里。用户头像、UGC 图片、后台运营随时上传的 banner,编译时根本不存在,只能走运行时方案(自建转码服务或图片 CDN)。别硬把用户上传的图塞进构建流程。图片数量特别大时,构建时间会失控。前面反复强调增量缓存,就是因为这是真痛点。如果你的站点有上万张图,即便有缓存,首次全量构建和缓存失效后的重建依然可能是几十分钟级别。这种规模要认真评估,可能需要把图片处理拆成独立的、和主构建解耦的流水线,而不是塞进每次vite build。CI 缓存没配好,等于没做增量。增量的收益全押在 CI 缓存命中上。缓存 key 设计错了(比如把会频繁变的东西混进 key)、或者产物目录忘了缓存,每次 CI 还是全量重跑,本地看着快、CI 里慢得要死,这个坑很多团队踩过。动态尺寸需求,构建期给不了。如果你的图片显示尺寸是运行时才确定的(比如用户能任意拖拽缩放、或者布局高度依赖实时数据),预生成固定几档宽度就未必够用,这时运行时按需生成反而更合适。小图和图标别用这套。十几 KB 的小图标,切五个尺寸转两种格式的收益微乎其微,反而徒增文件数和构建负担。这类图更适合内联成 data URI 或用 SVG 精灵图,杀鸡不必用牛刀。一点选型建议收尾如果你现在就想动手,路径大致是:先用 sharp 手写脚本把原理跑通,理解多尺寸、多格式、srcset、不放大、按格式调质量这几个关键点;项目在 Vite 上就上 vite-imagetools 把它收编进构建,享受自动 srcset 和 hash 缓存;然后老老实实把 CI 缓存和增量做扎实,这是长期不被构建时间拖垮的关键。原图从哪来、怎么初步压?本地手工阶段有一批顺手的在线工具可以用,像 squoosh、tinypng、tudingai.cn 这类,先把设计师导出的巨大原图做一轮基础压缩和格式初筛,再喂给构建流程,能省下不少后续处理量。工具是工具,真正让图片体积长期可控的,还是把处理逻辑沉淀进构建期这套自动化机制。图片优化没有一招鲜。构建期自动化解决的是静态、已知、稳定这一大类图片,把它做好,首屏体积和加载体验能有肉眼可见的提升;剩下的运行时场景,交给该交给的方案。分清边界,各就各位,才是工程化该有的样子。