需求是怎么来的
做在线图片压缩,最常见的实现是给用户一个"压缩质量"滑块,0 到 100 随便拖。但真实场景里用户想要的往往不是"质量 70",而是"压到 500KB 以内"------报名系统限制、附件大小限制、上传接口限制,给的都是体积,不是质量。
用户自己拖滑块去凑这个体积,基本要试五六次。这个活应该程序来干。
为什么不能直接算
第一反应是能不能建个模型,输入目标体积直接解出质量参数。答案是不能。
JPEG 的编码体积取决于量化后非零系数的数量,而这和图片内容强相关。一张纯色图和一张树叶特写,同样 quality 0.8,体积能差一个数量级。同一张图上,quality 从 0.9 降到 0.8 掉的体积,也远大于 0.5 降到 0.4。这条曲线是单调的,但形状不可预测。
单调性是关键------它意味着虽然算不出来,但可以二分。
实现
javascript
async function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(
blob => (blob ? resolve(blob) : reject(new Error('图片编码失败'))),
type,
quality
)
})
}
async function encodeToTargetSize(canvas, mime, targetBytes) {
let lo = 0.05
let hi = 1
let best = null
let bestQuality = lo
for (let i = 0; i < 8; i++) {
const q = (lo + hi) / 2
const blob = await canvasToBlob(canvas, mime, q)
if (blob.size <= targetBytes) {
best = blob // 达标,记下来,继续往高质量试
bestQuality = q
lo = q
} else {
hi = q // 超了,往低质量压
}
}
// 兜底:最低质量都超标(目标体积给得太小),也得有东西返回
if (!best) {
best = await canvasToBlob(canvas, mime, 0.05)
bestQuality = 0.05
}
return { blob: best, quality: bestQuality }
}
几个设计上的取舍:
为什么是 8 次。 每一次 toBlob 都是一次完整的 JPEG 编码,2000 万像素的图单次编码在中端手机上要几百毫秒,次数直接决定体感。8 次把 quality 的区间收敛到 (1-0.05)/2^8 ≈ 0.004,已经远超实际需要的精度,再多纯属浪费。
为什么保留 best 而不是直接用最后一次的 q。 二分结束时 lo 和 hi 夹着的那个点不保证达标,最后一次试的很可能是超标的那一侧。必须在每次达标时把 blob 存下来,否则要多编码一次。
为什么下界是 0.05 不是 0。 quality 0 在部分浏览器上行为不一致,有的直接返回 null。0.05 已经糊到没法看了,够用。
几个会踩的坑
PNG 没有 quality 参数。 canvas.toBlob(cb, 'image/png', 0.5) 第三个参数会被直接忽略,PNG 是无损格式,画多少次都是一样大。所以按目标体积压这个功能对 PNG 根本用不了,要么在 UI 上禁用,要么引导用户转成 JPG 或 WebP 输出。这一点很多教程不说,实现完了才发现 PNG 那条路压根不动。
真要压 PNG 得走颜色量化(pngquant 那套),浏览器端没有原生支持,得上 wasm。
PNG 转 JPG 的透明区会变黑。 JPEG 不支持 alpha 通道,直接 drawImage 过去,原本透明的地方会被填成黑色。得先铺一层白底:
javascript
function flattenToWhite(canvas) {
const out = document.createElement('canvas')
out.width = canvas.width
out.height = canvas.height
const ctx = out.getContext('2d')
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, out.width, out.height)
ctx.drawImage(canvas, 0, 0)
return out
}
HEIC 浏览器不认。 iPhone 直出的照片是 HEIC,createImageBitmap 和 <img> 都解不了(Safari 之外基本全军覆没)。要先用 heic2any 之类的库转成 JPEG 再进 canvas。这个库体积不小,建议按需动态 import,别打进主包。
先缩尺寸再压质量。 这条比上面所有优化都管用。手机直出 4032×3024 的图,用户实际只是要发个附件,先 resize 到 1500px 宽再编码,体积掉七八成,而且掉的是"看不出来"的那部分。只调 quality 是在已经过剩的分辨率上做有损压缩,性价比差得多。
一点补充
整套逻辑是有损的------canvas 解码再重编码,即使 quality 给 1 也不等于原图。所以别在 UI 上写"无损压缩",那是骗人的,真无损得走 pngquant / mozjpeg 那条线。
这套实现跑在 forxi.cn 的图片压缩里,纯浏览器本地跑,图片不上传服务器,需要对照效果的话可以直接去试。批量处理和 HEIC 那两条分支也是同一套 encodeToTargetSize,只是外面包了个队列。