vlambda博客
学习文章列表

二分查找法实现最优图片压缩体积并上传

摘要

针对目前流行的纯前端压缩图片解决方案大多改变了图片尺寸或者压缩后的图片体积无法准确达到期望值范围内的问题,通过运用二分查找法寻找最优压缩点的来实现既不改变图片体积,又能控制压缩后图片体积精度的效果。在同类型的纯前端压缩解决方案中对比,本文提出的方案没有改变图片体积,且压缩后的体积精度可控,能达到预期效果。研究结果表明,这种方案科学且靠谱。🤓🤓🤓

前言

首先这真的不是一篇 炒冷饭的文章,尽管掘金上讲述纯前端压缩的文章很多⏬  但是细看这些方案的压缩效果都不能完全满足我的需求,在张鑫旭老师这篇文章中提出的压缩方案核心原理是运用CanvasdrawImage()方法,将大图片绘制在小Canvas画布上实现等比例图片压缩,但是这种压缩方式压缩出来的图片体积不可控。很多人可能会说压缩后图片体积不是越小越好吗?其实不是的,考虑这样一个应用场景。在证券基金公司开户、证件识别等场景下,要求传输的证件照片不能过大,太大的弊端太多了,也不能太小,更不能改变图片尺寸,否则会造成后端的OCR系统识别失败的概率大大攀升,无法读取到证件上的一些关键信息。因而这就要求前端对于图片压缩后的体积要尽可能接近期望值。另一类解决方案是使用Canvas.toDataURL(type,encoderOptions)来实现压缩,核心原理是设置这个encoderOptions的值,即图片输出质量来实现压缩效果。那么对于一个给定的文件大小期望值,我们是否能通过设置图片质量使得输出文件最接近期望值是一个值得探索的问题。

实现思路

结合上述两种方案实现原理,使用Canvas.toBlob(callback, type, encoderOptions)来实现,采用二分查找法来寻找最优的压缩比例(encoderOptions的值)。
为什么使用的是toBlob()而不是toDataURL呢? 这主要是因为toBlob这个方法是异步的,而toDataURL是同步的,因而在多进程中toDataURL它会阻塞UI渲染。具体来说toDataURL主要执行以下三步操作,将位图从GPU移到CPU,在CPU将其转变为图片格式后,再使用base64编码将其转变为base64字符串。而toBlob方法将位图从GPU移动CPU虽然是同步操作的,但是它将文件转为图片格式的这个过程是异步的,不会阻塞UI渲染,而且它不需要使用base64编码方式将其转为字符串,这也就意味着toBlob事半功倍。除此之外,同样的图片文件处理结果toDataURL的结果占用的内存比toBlob要大,toDataURL返回结果是USVString,它对应 unicode 标量值的所有可能序列的集合,在JavaScript中返回时,USVString 映射到 String。它的体积比原二进制文件要高37%左右,并且每次您在某个地方使用此字符串时,例如作为DOM元素的src *,或者通过网络请求发送该字符串时,该字符串都将会重新得到分配到内存。因此,你几乎永远都不需要使用toDataURL,因为toDataURL能做的,toBlob能做的更好。

下面来细分实现步骤,具体如下:
tsx代码

 <input type={"file"} ref={frontRef} accept={"image/*"} onChange={uploadFile} multiple={true}/>

1. 获取上传文件

const uploadFile = (files: any) => {
   const file = frontRef.current.files[0];
   let src = getObjectUrl(file);
   setCardFront(src);
   const fileType = file.type.split('/')[1];
   if (fileType !== 'png' && fileType !== 'jpg' && fileType !== 'jpeg') {
     Toast.info('请上传png,jpg,jpeg格式的图片!');
     return;
   }
 };

2. 生成预览地址

用fileReader和URL.createObjectURL都可以,但是后者兼容性和性能更好一些。当然,如果不需要兼容一些老的机型,直接拿图片文件生成的base64做预览图也可以。

const getObjectUrl = (file: any) => {
   let url = null ;
   if (window.createObjectURL!=undefined) { // basic
     url = window.createObjectURL(file) ;
   } else if (window.URL!=undefined) { // mozilla(firefox)
     url = window.URL.createObjectURL(file) ;
   } else if (window.webkitURL!=undefined) { // webkit or chrome
     url = window.webkitURL.createObjectURL(file) ;
   }
   return url ;
 };

3. file文件到img

const fileToImage = blob => new Promise( resolve => {
 const img = new Image();
 img.onload = () => resolve(img);
 img.src = getObjectUrl(blob);
});

4. 将img转换为canvas

const imgToCanvas = (img) => new Promise(resolve => {
 const canvas = document.createElement('canvas');
 const context = canvas.getContext('2d');
 const imgWidth = img.width;
 const imgHeight = img.height;
 canvas.width = imgWidth;
 canvas.height = imgHeight;
 // 尺寸与原图保持一致,因此等高等宽
 context.clearRect(0, 0, imgWidth, imgHeight);
 context.drawImage(img, 0, 0, imgWidth, imgHeight);
 resolve(canvas);
});

5. 开始压缩

const compress = (originfile, limitedSize) =>
 new Promise(async (resolve, reject) => {
   const originSize = originfile.size / 1024; // 计算文件大小 单位为kb
   // 若小于limitedSize,则不需要压缩,直接返回
   if (originSize < limitedSize) {
    resolve({ file: originfile, msg: '体积小于期望值,无需压缩!'});
     return;
   }
   // 将获取到的文件恢复成图片
   const img = await blobToImage(originfile);
   // 使用此图片生成需要的canvas
   const canvas = await imgToCanvas(img);
   // 为规避js精度问题,将encoderOptions乘100为整数,初始最大size为MAX_SAFE_INTEGER
   const maxQualitySize = { encoderOptions: 100, size: Number.MAX_SAFE_INTEGER };
   // 初始最小size为0
   const minQualitySize = { encoderOptions: 0, size: 0 };
   let encoderOptions = 100;  // 初始质量参数
   let count = 0; // 压缩次数
   let errorMsg = '';  // 出错信息
   let compressBlob = null; // 压缩后的文件Blob
   //  压缩思路,用二分法找最佳的压缩点 最多尝试8次即可覆盖全部可能
   while (count < 8) {
     compressBlob = await canvastoFile(canvas, 'image/jpeg', encoderOptions / 100);
     const compressSize = compressBlob.size / 1024;
     count++;
     if (compressSize === limitedSize) {  // 压缩后的体积与期望值相等  压缩完成,总共压缩了count次
       break;
     } else if (compressSize > limitedSize) { // 压缩后的体积比期望值大
       maxQualitySize.encoderOptions = encoderOptions;
       maxQualitySize.size = compressSize;
     } else {    // 压缩后的体积比期望值小
       minQualitySize.encoderOptions = encoderOptions;
       minQualitySize.size = compressSize;
     }
     encoderOptions = (maxQualitySize.encoderOptions + minQualitySize.encoderOptions) >> 1;
     if (maxQualitySize.encoderOptions - minQualitySize.encoderOptions < 2) {
       if (!minQualitySize.size && encoderOptions) {
         encoderOptions = minQualitySize.encoderOptions;
       } else if (!minQualitySize.size && !encoderOptions) {
         errorMsg = '压缩失败,无法压缩到指定大小';
         break;
       } else if (minQualitySize.size > limitedSize) {
         errorMsg = '压缩失败,无法压缩到指定大小';
         break;
       } else {  //  压缩完成
         encoderOptions = minQualitySize.encoderOptions;
         compressBlob = await canvastoFile(canvas, 'image/jpeg', encoderOptions / 100);
         break;
       }
     }
   }
   // 压缩失败,则返回原始图片的信息
   if (errorMsg) {
     reject({
       msg: errorMsg,
       file: originfile,
     });
     return;
   }
   const compressSize = compressBlob.size / 1024;
  console.log(`最后一次压缩后,encoderOptions为:${encoderOptions},大小:${compressSize}`);
   // 生成文件
   const compressedFile = new File([compressBlob], originfile.name, {
     type: 'image/jpeg',
   });
  resolve({ file: compressedFile, compressBlob, msg: '压缩成功!'});
 });

为什么最多尝试八次即可覆盖全部可能呢? 这是因为2^7 = 128, 而1/128=0.0078125,而encoderOptions的最小颗粒度为0.01,因此再压缩下去已经没有意义了。
注意: canvas.toBlob在MDN文档上明确说明,可选参数encoderOptions是Number类型,值在0与1之间,当请求图片格式为image/jpeg或者image/webp时用来指定图片展示质量。也就是说压缩的时候只支持jpeg/webp这两种格式,因此,需要将type设定为image/jpeg

6. 压缩完成

后端与我约定最大体积为300kb,拿到压缩结果res后就可以愉快的发给他们了。🤗🤗🤗

 compress(file, 300).then((res: any) => {
     Toast.success(res.msg);
     console.log(res);
   }, (err: any) => {
     Toast.fail(err.msg);
   });

7. 补充

很多时候,我们除了关注压缩后图片的体积之外,还期望从感官上直观地来体验一下压缩后的图片清晰度如何,那么可以这样来下载压缩以后的图片进行观察。

const downLoadImg = (blob) => {
 const link = document.createElement("a");
 link.href = URL.createObjectURL(blob);
 link.download = 'fileName'; // 文件命名
 link.click();
 link.remove();
 URL.revokeObjectURL(link.href);
};

总结

写这篇文章的时候,还调研了很多网上使用率较高的压缩组件,比较流行使用的有lrz,它的使用语法如下:
(file, [options]);
1、file 通过 input:file 得到的文件,或者直接传入图片路径
2、[options] 这个参数允许忽略
3、width {Number} 图片最大不超过的宽度,默认为原图宽度,高度不设时会适应宽度。
4、height {Number} 同上
5、quality {Number} 图片压缩质量,取值 0 - 1,默认为0.7
6、fieldName {String} 后端接收的字段名,默认:file
无法将图片压缩到指定体积附近,依旧需要你去探索quality的取值。还有一个插件是image-conversion,它有一个api叫compressAccurately(file, config) → {Promise(Blob)},设置容忍误差度后,能够将图片体积压缩到指定值的附近,但是还集成了很多其他不需要的功能,因此不如自己动手丰衣足食,顺便深入学习学习,探究其原理,岂不美哉😆