1. 图片体积问题的处理边界:为什么压缩必须放在构建阶段
1.1 一个实际项目里被图片体积拖垮的例子
先说一个真实情况。我接手的前端项目跑了一年多,dist 目录里图片累计到了 18MB,其中三张开屏图每张都在 2MB 以上,用户加载首页光图片就得等好几秒。团队不是不知道这个问题,但压缩规范写在文档里和没写一样,设计师出完图直接丢过来,开发也没空一张张手动跑工具,最后上线全看运气。
后来我整理思路,发现问题的根源不在于"某张图没压缩",而在于压缩这个动作被放在了"人"身上。只要是人去执行,总有漏网之鱼。所以正确解法是把压缩从"手动流程"变成"自动流程",让它成为构建流水线的固定环节。
为什么不选提交前钩子或者 CI 脚本?这两个方案我也考虑过。提交前钩子只能管住开发者本地,如果某次提交绕过了安全检查,就又是漏网之鱼;CI 脚本的问题在于它作用于整个仓库,但你很多时候只想处理某一个 Vite 构建项目的产物,隔离性不够。最后选 Vite 插件,是因为它天然就挂在构建链路上,只要项目是用 Vite 打包的,这个插件必然被执行,不存在遗漏问题。
1.2 插件到底要解决哪几个问题
这个插件的目标很聚焦,就三件事:
第一,把产物里的 PNG/JPEG 图片自动压缩一遍。第二,生成对应的 WebP 版本。第三,把 HTML、CSS、JS 里的图片引用自动改写为 WebP。
除了这三件事之外,它不应该管也不需要管。比如图片源文件的质量管理,那应该在设计师出图时就控制好;运行时懒加载和按需加载,那是业务代码的事情,插件管不了;接口直接返回的图片 URL 更是服务器端的事,和打包产物无关。把边界想清楚,插件才不至于越做越复杂。
这里还需要说清楚一个前提:插件处理的是"已经进入 Vite 构建管线的图片资源"。也就是说,图片要么是通过 import 引入的,要么是放在 public 目录里被打包进产物的。如果图片是运行时从接口拿到的远程 URL,插件完全不碰,因为构建阶段根本看不到这张图。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计:在构建链路的哪个环节动手最合适
2.1 为什么选 generateBundle 而不是其他钩子
Vite 插件系统提供了很多钩子,我一开始也试错过。用过 transform,想着在源码层直接把图片引用替换成 WebP,结果发现需要处理 import 语句的语法解析,还要考虑不同框架的导入方式,复杂度瞬间上来了,而且 transform 阶段根本拿不到最终产物路径,替换完引用之后文件可能不在那个位置,全是问题。
transformIndexHtml 只对 HTML 文件生效,但 CSS 里的 url() 引用照样处理不了。
最后我停在了 generateBundle 这个钩子上。先说结论:generateBundle 是 Vite 构建阶段生成产物到内存、尚未写入磁盘的最后一个环节,在这个钩子里你可以拿到完整的 bundle 对象,里面包含了这次构建产出的所有文件内容。这个位置有两个决定性优势:
- 产物路径是最终定稿的路径,带 hash 也全都确定了,替换引用时不会出现路径漂移。
- 文件内容以 Buffer 形式挂在
bundle对象上,直接修改内容后抛回给 Vite,后面写盘的就是改动后的文件,不需要额外做磁盘文件的读写覆盖。
有一点要注意,generateBundle 和 writeBundle 的区别很关键。writeBundle 触发时产物已经写到磁盘了,如果你在 writeBundle 里再改文件,要么自己手动覆盖,要么留下多余的临时文件,都不干净。generateBundle 是在写入磁盘前,你改完的 bundle 就是最终写盘的内容,所以选择它是清晰的。
2.2 数据流:从资源扫描到产物替换的完整路径
理解了这个插件的整体数据流,后面的代码就好读了:
- Vite 完成打包构建,所有文件进入
bundle对象。 - 插件遍历
bundle,找出类型为asset且后缀是图片的文件。 - 对每个图片文件,用 sharp 读取 Buffer,做压缩,生成压缩后的图片和新 WebP 文件。
- 把压缩后的 Buffer 替换回原 asset 的
source属性。 - 把生成的 WebP 文件作为新 asset 塞进
bundle。 - 遍历所有
chunk(JS/CSS),把代码中的旧图片路径替换为.webp后缀。
这里面第 6 步最容易被忽略。很多人只做了压缩,生成了 WebP,但页面加载的还是 PNG,等于白做。引用替换是整个插件"最后一公里",不做或者没做对,前面全是白费。
3. 代码实现:压缩和 WebP 转换的完整逻辑
3.1 技术选型:为什么是 sharp 而不是 imagemin
选型时我主要对比了 sharp 和 imagemin 两个方案。
imagemin 成名很早,插件生态丰富,但问题也很明显。它本质是 JS 调用外部编码引擎,大量图片处理时性能跟不上。我在同一个项目里做过基准测试:500 张合计约 25MB 的图片,imagemin 用 mozjpeg 加 webp 插件跑完接近 20 秒;用 sharp 只要 3.2 秒。体验差距是数量级的。
另外,imagemin 要把压缩 JPEG、压缩 PNG、转 WebP 拆成多个插件来组合,有时还有原生依赖版本打架的兼容性问题。sharp 基于 libvips,内置了 JPEG/PNG/WebP 全套编解码能力,一个依赖全搞定,API 设计也更现代。
所以最终选了 sharp,这个决定后来被验证是对的。插件的核心逻辑非常简洁,没有复杂的依赖管理。
3.2 插件骨架与压缩模块
先看整体骨架:
typescript复制import { Plugin } from 'vite'
import sharp from 'sharp'
import path from 'node:path'
interface ViteCompressImageOptions {
quality?: number // 压缩质量,默认 75
convertWebp?: boolean // 是否转 WebP,默认 true
skipOriginal?: boolean // 是否删除原图,默认 false
include?: RegExp[] // 额外包含规则
exclude?: RegExp[] // 排除规则
}
const IMAGE_EXT = /\.(png|jpe?g|gif|svg|webp)$/i
const RASTER_EXT = /\.(png|jpe?g|webp)$/i
function viteCompressImage(options: ViteCompressImageOptions = {}): Plugin {
const { quality = 75, convertWebp = true, skipOriginal = false } = options
return {
name: 'vite:compress-image',
async generateBundle(_outputOptions, bundle) {
// 第一轮:收集图片 asset
const imageAssets: { fileName: string; asset: any }[] = []
for (const [fileName, item] of Object.entries(bundle)) {
if (item.type !== 'asset') continue
if (!RASTER_EXT.test(fileName)) continue
// 处理 include/exclude 规则
if (options.exclude?.some(reg => reg.test(fileName))) continue
if (options.include && !options.include.some(reg => reg.test(fileName))) continue
imageAssets.push({ fileName, asset: item })
}
// 第二轮:逐个处理图片
for (const { fileName, asset } of imageAssets) {
await optimizeImage(fileName, asset, { quality, convertWebp, skipOriginal })
}
}
}
}
注意第一轮和第二轮要分开。第一轮先搜集所有图片,如果你在遍历 bundle 的同时修改 bundle(比如往里面塞新的 WebP asset),会导致遍历过程中对象不断变化,容易出意想不到的问题。先收集完再统一处理,逻辑清晰也不容易踩坑。
optimizeImage 的核心逻辑:
typescript复制async function optimizeImage(
fileName: string,
asset: any,
opts: { quality: number; convertWebp: boolean; skipOriginal: boolean }
) {
const inputBuffer: Buffer = asset.source
const image = sharp(inputBuffer, { animated: false })
// 获取原图信息,后面要判断是否有透明通道
const metadata = await image.metadata()
// 压缩原图
let outputBuffer: Buffer
if (fileName.endsWith('.png')) {
outputBuffer = await image
.png({ quality: opts.quality, compressionLevel: 9 })
.toBuffer()
} else {
outputBuffer = await image
.jpeg({ quality: opts.quality, mozjpeg: true })
.toBuffer()
}
// 压缩后的 Buffer 替换回 bundle,写盘的就是压缩后的内容
asset.source = outputBuffer
// 生成 WebP 版本
if (opts.convertWebp) {
const webpBuffer = await sharp(inputBuffer)
.webp({ quality: opts.quality })
.toBuffer()
const webpFileName = fileName.replace(/\.(png|jpe?g)$/i, '.webp')
this.emitFile({
type: 'asset',
fileName: webpFileName,
source: webpBuffer
})
}
}
这里有两个细节要解释。第一,mozjpeg: true 是 sharp 里的一个关键参数,它启用 mozjpeg 编码器,能显著提升压缩率,同一个质量下体积比默认编码器小 5%~10%。第二,emitFile 是 Rollup 提供的标准接口,它会把新文件注册进构建产物,名字如果和已有文件冲突,Vite 会自动加 hash 后缀,这也是为什么生成的 WebP 能安全地和原图共存。
第三,WebP 的转换我特意用原始 inputBuffer 而不是压缩后的 outputBuffer。原因是 WebP 本身就是有损格式,如果先用 JPEG 压缩一遍再转 WebP,就会叠加两次有损压缩,画质损失翻倍。直接用原始 Buffer 转换,WebP 可以从原始数据里保留更多细节。
3.3 引用替换:这是最容易翻车的一步
生成 WebP 文件是第一步,紧接着要把代码里的引用替换掉,否则生成的 WebP 根本不会被页面加载。
替换逻辑是这样的:
typescript复制async function generateBundle(_outputOptions, bundle) {
// ...前面的图片处理逻辑...
// 替换引用
if (!opts.convertWebp) return
const webpRenameMap = new Map<string, string>()
for (const [fileName, item] of Object.entries(bundle)) {
if (item.type !== 'asset') continue
if (!RASTER_EXT.test(fileName)) continue
const webpFileName = fileName.replace(/\.(png|jpe?g)$/i, '.webp')
webpRenameMap.set(fileName, webpFileName)
}
for (const [fileName, item] of Object.entries(bundle)) {
if (item.type !== 'chunk') continue
if (!/\.(js|css)$/.test(fileName)) continue
let code = item.code
for (const [oldName, newName] of webpRenameMap) {
code = code.split(oldName).join(newName)
}
item.code = code
}
}
为什么用 split/join 而不是正则?因为文件名里可能包含各种字符,用正则还得做转义处理,一个不小心就匹配错。split/join 本质是全文本替换,简单可靠。
但这里涉及一个坑:替换之后,原图就失去了引用。浏览器会直接请求 WebP 文件。这就意味着你必须考虑 WebP 的兼容性。目前现代浏览器对 WebP 的支持已经非常成熟,但如果你面向的场景中有较旧版本的 Safari 14 以下,或者一些内嵌 WebView,WebP 仍然可能解码失败。所以 skipOriginal 参数默认是关闭的,也就是说原图会保留在产物中作为兜底。开发时用低版本环境检查一遍,如果发现兼容性问题,可以配置关闭 WebP 转换,只保留压缩。
3.4 配置项设计:要简单但留有扩展空间
配置项我刻意设计得很少,就四个:quality、convertWebp、skipOriginal、include/exclude。
quality 控制压缩力度,默认 75 是压缩比和画质的平衡点。如果你追求极致体积,可以设到 60;如果图片是设计感强的视觉稿,建议设到 85 以上,避免肉眼可见的压缩痕迹。
convertWebp 开关让使用方可以按需选择是否生成 WebP。有些项目可能暂时不需要迁移到 WebP,只做压缩就够。
skipOriginal 决定压缩后原图去留。通常用于对 WebP 兼容性有十足信心的项目,可以直接删掉原图,省下空间。默认关,谁要打开谁心里有数。
include/exclude 是普适的正则过滤规则。比如你可以只压缩 assets/images 目录下的图片,排除 assets/icons 下的 SVG 图标。
建议不要一开始就把配置项做得很复杂。使用者真正高频需要设置的其实只有 quality 和 skipOriginal,其他都是锦上添花。
4. 踩坑实录:从真实项目里挖出来的几个问题
4.1 小图片被 base64 内联后,插件根本摸不到它
这是我在验证插件效果时遇到的第一个诡异问题。插件写了,构建也跑了,但打开 dist 一看,有些小图标明明压缩效果很差,插件却没处理。排查半天发现原因:Vite 默认 assetsInlineLimit 是 4096 字节,也就是说小于 4KB 的图片会被直接转成 base64 字符串内联进 JS 或 CSS,根本不会以 asset 文件的形式出现在 bundle 里。
插件扫描 bundle 时找不到图片文件,自然就不用处理。
这个问题的解决方式有三种:一是把 assetsInlineLimit 调小或设为 0,强制所有图片都输出为独立文件;二是内联本身也是体积优化,因为 base64 编码反而会增加 33% 的体积,所以小图内联对性能可能反而更差;三是如果真的想让小图也走 WebP,那必须在内联策略上整体调整。
我最后的选择是 assetsInlineLimit 设为 0,然后由插件统一处理所有图片。虽然会造成 HTTP 请求数增加,但对图片压缩这个目标来说更可控。实际项目里如果有很多小图标,建议用 SVG sprite 或字体图标代替,这是另一个话题了。
4.2 大量图片同时处理导致 Node 内存溢出
第一次跑构建是在一台 8GB 内存的笔记本上,项目里有四五百张图片。构建到一半直接报 JavaScript heap out of memory 崩溃了。原因很典型,sharp 处理图片时,原图 Buffer、压缩后 Buffer、WebP Buffer 都会占用内存,而我又是在循环里同步读取再异步处理,Node 默认堆内存上限大约 1.5GB~2GB,图片一多就会被打爆。
有没有提高内存上限的办法?有,NODE_OPTIONS="--max-old-space-size=4096" vite build 这样能缓解,但治标不治本,图片多到一定程度还是会崩,而且调大内存上限只是推迟了问题。
真正靠谱的解法是限制并发。sharp 是异步处理,但单张图片吃内存不释放,并发太多就会堆叠。我最后用了一个简单的 p-limit 或者自己写一个并发池控制并发数,实测设置并发为 3 时,构建耗时和内存占用最平衡。串行最稳但慢,并发 5 以上内存又开始吃紧。
这里还需要注意 sharp 处理完一张图后,要把 Buffer 引用置空,让垃圾回收能及时回收。虽然大多数时候不用担心,但在大图片批量场景下主动做资源释放是必要的。
typescript复制import { pLimit } from 'p-limit'
const limit = pLimit(3)
await Promise.all(
imageAssets.map(({ fileName, asset }) =>
limit(() => optimizeImage(fileName, asset, options))
)
)
4.3 PNG 透明通道和 CMYK 图片的坑
PNG 图片转 WebP 时有个隐蔽的问题:如果 PNG 带透明通道,转出来的 WebP 也会带 alpha 通道,但文件体积会比不带透明度的大很多。如果你的图片其实不需要透明效果,可以在转换前用 flatten 方法铺一层背景色,体积能明显下降。
但反过来也有坑:有些 PNG 图确实需要透明度,此时 flatten 会把透明部分变成白色或黑色背景,视觉上直接翻车。所以 flatten 要不要用,必须基于原图是否有真实透明需求来决策,不能在插件里一刀切。
另一个更诡异的坑是 CMYK 色域的 JPEG 图片。这种图在浏览器里显示没问题,但 sharp 处理时会报错,因为 libvips 默认不支持 CMYK 直接编码 WebP,需要先转换色彩空间。处理方式是:
typescript复制const image = sharp(inputBuffer)
.toColorspace('srgb')
toColorspace('srgb') 不仅解决报错,还能保证输出图片在各平台色彩一致。这个细节在桌面端和移动端的色差对比中体现得很明显,不遇到一次不会意识到。
4.4 动图被错误转换后变成静态图
项目里有几张 GIF 动图,插件跑完之后它们全变成了静态的 WebP 图片。原因很直接:sharp 的 WebP 编码不支持动画帧,它只读取了 GIF 的第一帧。
这个问题要分两个层面解决。
第一个层面,插件层面要识别 GIF 文件并跳过 WebP 转换,只做压缩。第二个层面,如果业务上需要动画,目前最靠谱的方案是保留 GIF 原格式,或者由设计师转成视频(如 WebM/MP4)后前端用 video 标签播放。插件里直接跳过 GIF 转 WebP 是最稳妥的,因为动图的需求一旦被破坏,视觉问题会很严重。
typescript复制if (fileName.endsWith('.gif')) {
// 动图跳过 WebP 转换,只做压缩
return
}
碰到大 GIF 时,sharp 的压缩效果也有限,通常还需要用 gifsicle 或在线工具做帧优化,但这是另一个话题了。
5. 使用方式与验证效果:如何确认插件真的在起作用
5.1 配置方式
插件的使用方式很简单,在 vite.config.ts 里注册即可:
typescript复制import { defineConfig } from 'vite'
import viteCompressImage from './plugins/vite-compress-image'
export default defineConfig({
resolve: {
alias: {
'@': '/src'
}
},
build: {
assetsInlineLimit: 0
},
plugins: [
// 其他插件...
viteCompressImage({
quality: 75,
convertWebp: true,
skipOriginal: false
})
]
})
这里 assetsInlineLimit: 0 是关键,为什么必须设,前面已经解释过。如果你的项目里小图走了内联,插件就不会处理它们,这是最常见的"插件没生效"假象。
5.2 验证压缩效果的三步法
插件写完,不能光看构建成功就完事,要验证效果。我一般按这三步来:
第一步,构建完成后看 dist/assets/ 目录里的图片体积。对比一下同类图片,WebP 版的体积通常比原图小 30%~60%,如果没有明显差异,检查一下原图是不是已经压缩到了极限。
第二步,用浏览器打开构建后的页面,打开 DevTools 的 Network 面板,过滤 img 请求,确认图片响应的 Content-Type 是 image/webp。这里还要确认请求 URL 没有 404,说明替换后的路径正确。
第三步,把原图和 WebP 并排放在一起做视觉对比。重点看有渐变、文字、人脸、边缘细节的区域,确认是否出现色带、噪点、毛刺。如果发现明显劣化,把 quality 调高一档,或者对特定目录设置例外。
一个参考基准:一张 2MB 的 JPG 照片,sharp 质量 75 转 WebP 后通常在 200~400KB。一张 1MB 的 PNG 导出图,转 WebP 后通常在 200~300KB。如果你的压缩率远远小于这个量级,很可能是原图已经被压缩过了,再做一次有损压缩只会带来画质损失而体积变化不大。
5.3 这个插件还能怎么扩展
插件目前的实现已经能覆盖 90% 的项目需求,但扩展空间还是有的:
- 支持 AVIF 格式。AVIF 比 WebP 压缩率更高,但编码耗时也更大,可以作为可选项配置。
- 图片降采样。有些图片实际展示尺寸远小于原始尺寸,比如原图 4000x3000 只在页面显示 800x600,可以在插件里配置最大宽高,先缩放再压缩。
- 支持生成多个尺寸。配合 srcset 做响应式图片,一个插件输出多种规格。
- 接入外部运维系统。比如压缩完成后生成一个报告 JSON,列出每张图压缩前后的体积,方便跟踪。
不过个人建议是:别一口气全加上。插件最核心的价值是稳定可靠地解决图片体积问题,功能加得越多,维护成本越高,出问题的概率也越大。等真实需求出现再按需加,比提前做一堆用不上的功能要更好。
我自己的体会是,写这个插件花了半天,但后续调优和踩坑花了两天。最花时间的不是 sharp 的 API,而是那些边界情况——内联图片、透明通道、动图、色彩空间。每一个都代表一种真实存在的图片需求。踩过一遍之后,再遇到类似问题基本能一眼定位。如果你也在做图片处理相关的构建插件,希望这篇能帮你避开这些坑。
