纯前端把图片压缩到指定体积:canvas.toBlob 配合二分查找

需求是怎么来的

做在线图片压缩,最常见的实现是给用户一个"压缩质量"滑块,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。 二分结束时 lohi 夹着的那个点不保证达标,最后一次试的很可能是超标的那一侧。必须在每次达标时把 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,只是外面包了个队列。

相关推荐
是立不是利2 小时前
CSS 入门与进阶:从选择器到响应式布局的完整指南
前端·css
镭立智能制造2 小时前
电线电缆换线频繁?MES系统如何缩短换线时间40%
前端·制造
志尊宝2 小时前
Vue3 零基础每日笔记(018):按键与鼠标修饰符——回车搜索、Ctrl+S 保存这样写
javascript·vue.js·笔记
Amos_Web3 小时前
Rspack 源码解析(五):CodeGenerationPass 与模块代码生成
前端·前端框架·源码阅读
八荒启·交互动画3 小时前
Web特效06——WebGL深挖:顶点着色器和片元着色器到底在干什么
前端·网页特效·八荒启-交互动画·八荒启
这锅我不背哈3 小时前
前端权限控制实践:基于 RBAC 的完整落地方案
前端
可乐鸡翅yeah_3 小时前
HLS流媒体首屏起播慢深度优化,从分片、索引、播放器全链路调优
开发语言·javascript·ecmascript·m3u8·m3u8在线
星空3 小时前
Map<String, String>`Map`是接口,不能直接 new
java·前端·算法
开开心心就好3 小时前
低年级识字练笔顺工具,电脑和安卓端都能用
前端·javascript·网络·安全·scala·erlang·语音识别