JS脱机版:图片压缩工具

1,656 阅读19分钟

前言

最近项目中对图片的要求比较高,经常会进行图片压缩和修改分辨率的操作,久而久之就觉得自己写一个吧,于是花了一天的功夫完成了这个脱机版图片压缩工具,无需服务器,本地即可运行。

参考了张鑫旭大佬的两篇文章,链接放在文章的最下方,感兴趣的可以深入了解下。

项目Github地址

效果展示

首先看看图片上传的效果:

再看看在线获取图片的效果:

CSS部分不是此次讨论的重点,可以暂时不用考虑,为了美观,这里参考了antd的部分组件样式。

原理介绍

原理其实很简单,就是canvas的应用。用户上传图片之后我们可以拿到图片的base64内容,之后使用canvasdrawImage方法画出图片,再使用drawImage方法时,可以定义图片的宽高和图片质量,不过需要注意的时这里的图片质量选项之后在导出图片为jpg时才能有用,导出格式为png时是没有任何作用的。

图片大小的计算其实是通过图片的base64内容计算出来的。这里就要涉及一点base64原理了,拿出小本本记一下:

base64的出现是因为要兼容除了英文以外的其他语言,因为中文或者日文无法在服务器或者网管上进行有效的处理,经常会出现乱码,这时base64就出现了,转化成了一串编码就可以随意传输了,接收到之后再翻译一下就好。

base64的原理比较简单,base64有一个自己的表,里面每个字节都有着自己的代号。

首先,将需要待转换的字符串分成三个一组,每个字节的大小时8bit,那么三个字节就有24个二进制位。

然后,再将上面的24个二级制位分成4组,每组6个。

接下来,在每组前面增加两个0,于是每组变成了8个二级制为,4组总共32个二进制位,总共4个字节。

最后,根据之前说过的base64转换表,将这些二级制位翻译一下,就得到了最终的base64字符串。

那么这里有两个问题:首先,因为在每组之前都增加了两个0,所以base64编码之后的文本会比原生文本大三分之一左右。其次就是为什么要使用3个字节一组呢?那是因为6和8的最小公约数时24,3个8和4个6正好都是24。

还有一个特殊情况就是万一有的位数不足怎么办呢?分的时候可能字节数不足三个。如果字节数是2个,可以拿到16个二进制位,6个一组之后,最后一组差两个,用0补齐正好3组,可是第四组呢?这时候就需要用=来假装这里是一个组了,强行凑够4组。若是只有一个字节数,那么12除以6等于2,还差两个组才能到四,所以需要两个=来凑个四个组。为了4组也是蛮拼的。所以说base64编码中可能会出现一到两个=

知道这些之后我们就可以反向计算文件体积了,代码如下所示:

const getFileSize = (base64Url) => {
  //  去掉无用头部信息(data:image/png;base64,)
  let baseStr=base64Url.substring(base64Url.indexOf('base64,')+'base64,'.length);
  //  去掉”=“
  baseStr = baseStr.replace(/=/gi, '');
  // 进行计算
  const strLen=baseStr.length;
  return strLen-(strLen/8)*2
}

首先去掉头部的类型标志,pngjpeg之类的标识。接下来用正则去掉等号。最后减去填充的0。每8字符串就有两个0,所以用整体长度除以8,再乘以2,可以得到所有0的个数。用总体的长度减去0的个数,即可得到生下的字节数,也就是真正的字节数,再除以1024,得到最终的大小,单位为KB

需要注意的是MACwindows的文件体积计算方式不同,MAC是1000进制的,而windows是1024进制的,所有会有一些区别,这个区别从MAC硬盘的容量基本都接近足量也可以看出来。

页面布局

明显可以看出来,整个页面由两部分构成,左侧是图片压缩信息修改的部分,右侧是使用说明和预览部分。

首先左侧是以一个wrapper,用来包裹左侧所有内容。里面分为若干个小部分,首先是最上方的图片自定义宽高部分,代码如下所示:

<div class="wrapper">
  <div class="size-options">
    <p class="sub-title">图片自定义宽高</p>
    <ul>
      <li class="m-b-10">
        <span>宽度:</span>
        <input class="input-text" type="text" id="custom-width" placeholder="默认值为图片原始尺寸" onChange="updateImage()">
      </li>
      <li>
        <span>高度:</span>
        <input class="input-text" type="text" id="custom-height" placeholder="默认值为图片原始尺寸" onChange="updateImage()">
      </li>
    </ul>
  </div>
</div>

很简单的HTML,两个input而已。在size-options下面又增加了到处图片尺寸的配置。代码如下:

<div class="wrapper">
  // 其他内容
  <div class="clarity-options">
    <p class="sub-title">导出图片尺寸</p>
    <ul>
      <li>
        <input name="fileType" type="radio" value="jpeg" checked onChange="clarityWeightChange(value)">
        <label class="radio-label">JPG</label>
        <input name="fileType" type="radio" value="png" onChange="clarityWeightChange(value)">
        <label class="radio-label">PNG</label>
      </li>
      <li class="file-type-option">
        <span>图形质量:</span>
        <input type="range" name="points" min="1" max="100" id="clarity" value="80" onChange="updateImage()" />
      </li>
    </ul>
  </div>
</div>

布局和size-options十分类似,多了一个HTML5中的range标签,会根据fileType的类型来展示和隐藏。再下面就是上传图片和在线图片地址的选项。代码如下:

<div class="wrapper">
  // 其他内容
  <input class="hidden" type="file" id="file">
  <button class="btn m-b-10" onClick="showFileUpload()">上传图片</button>
  <div>
    <span>在线图片:</span>
    <input class="input-text" type="url" placeholder="在线图片地址" onChange="getOnlineImage(value)">
  </div>
</div>

有一个隐藏的file控件,因为其样式十分不好调整,干脆就隐藏掉,用下面的button来控制其click事件。在线图片地址用的也是HTML5url新空间,但因为不在一个form中,所以好像用处不大,用text也没什么影响。最下面是图片信息的展示。代码如下:

<div class="wrapper">
  // 其他内容
  <div class="display">
    <div class="img-details hidden">
      <p>图片信息:</p>
      <p class="indent-2" id="img-size"></p>
      <p class="indent-2" id="img-origin-weight"></p>
      <p class="indent-2" id="img-now-weight"></p>
      <canvas id="canvas"></canvas>
    </div>
  </div>
</div>

这就更没什么可说的,简单<p>标签,用来展示图片信息。至此,左侧wrapper的内容就完结了。下面是右侧instruction的内容。

instructionwrapper是并列关系,也就是JQuery中的siblingsinstruction中的内容比较少,也就是使用说明和图片的预览。代码如下所示:

<div class="instruction">
  <h4>脱机版图片压缩:使用说明</h4>
  <p class="tips">
    1.选择图片质量,<strong>注意:只有JPG格式可以调整图片质量,PNG格式无法调整</strong>
  </p>
  <div class="img-preview hidden">
    <button class="btn" onClick="downloadImage()">下载图片</button>
    <p>预览:</p>
    <img id="img-display" src="" alt="">
  </div>
</div>

ok,到这里HTML部分的内容就完成了,上面的代码中有很多function,暂时可以先不管了,下面会一一进行讲解。

具体实现

下面我们将从功能分析到具体的的实现来一步步完成这个脱机的图片压缩工具。

功能分析

功能嘛,其实主要就是上传并且压缩图片,但压缩的时候需要按照用户的需求来具体的压缩,尺寸和清晰度都要考虑到。

其次就是图片压缩完成后怎么给用户一个反馈,让用户意识到图片已经压缩完成了,这里分成两个部分,一个是直观的图片展示,一个是图片转换前后信息变化的展示。如此,不管是专业的用户还是普通用户都可以明显的看到压缩的效果。

最后,也是最重要的——下载功能。没有下载功能整个项目就等于不存在了。

综上所述,我们需要完成以下几点功能:

  1. 图片上传
  2. 图片尺寸自定义
  3. 图片清晰度自定义
  4. 图片预览
  5. 图片信息展示
  6. 图片下载

那么根据上面提到的页面布局,为了全面、合理的展示页面的全部内容,这里将1、2、3、5放在了左侧,右侧因为只有使用说明,空间比较大,用来实现4和6是一个比较好的选择。

公共变量的确认与更新

公共变量主要是用户输入的配置信息、图片的基础信息和基础HTML部件。

用户输入的信息有:自定义宽度、自定义高度、压缩程度、压缩类型。

图片基础信息有:原始宽度、原始高度。

基础HTML部件有:imgcanvas、下载链接。

用户输入信息和用户基础信息很好理解,基础的HTML部件其实就是这次项目中用来实际操作图片的东西,img部件用来存储用户上传的图片相关信息,同时若是用户上传图片地址的话,也可以存储在线图片的内容。

canvas部件不用多说,是用来画图的,是压缩图片的关键所在。

最后的下载链接为的是实现图片下载功能,将图片信息存在一个<a>标签中,之后模拟触发click事件,来实现图片的下载。

在弄清楚所有的部件之后即可开始开发初期的准备工作了,将公用变量和HTML绑定起来,HTML中内容的变化会触发公共变量的变化,也就是一个单向绑定。

首先想到的应该就是最上方图片自定义尺寸选项,其实这里的宽高可以不存成全局变量,因为只有在压缩图片是才会用到这两个参数,其他地方完全不会用到,所以直接在方法内部获取就好了,省了存成全局变量,混淆视听,同理还有压缩程度参数。

那么下面就是导出图片选项了,首先是导出图片的格式,这里存成imgType,初始值是jpeg。然后新建方法来同步信息,并且在选项为PNG时,隐藏图形质量选项,代码如下:

const clarityWeightChange = (value) => {
  imgType = value;
  document.getElementsByClassName("file-type-option")[0].classList.toggle('hidden');
  updateImage()
}

首先赋值给imgType变量,因为只有两个选项,所以直接toggle一下hidden属性即可,那么图形选项框就会在隐藏和展示之间来回切换,无需判断当前的值是PNG还是JPG了。再在DOM元素的onChange事件上绑定该方法即可:

<li>
  <input name="fileType" type="radio" value="jpeg" checked onChange="clarityWeightChange(value)">
  <label class="radio-label">JPG</label>
  <input name="fileType" type="radio" value="png" onChange="clarityWeightChange(value)">
  <label class="radio-label">PNG</label>
</li>

图片基础信息的获取,这一步相对来说比较复杂,以为要一层层触发,首先需要定义imgreader两个部件,上面说过img是用来存储文件信息的,那么reader是用来读取图片信息的,在读图片信息之后img才能获取到图片的宽高信息。那么问题来了reader是怎样获取到图片信息的呢?这时候就需要eleFile了,eleFile是用来获取到用户上传文件的input输入框的信息。

所以就是首先通过eleFile获取上传的图片信息,接下来触发reader的方法,获取到图片的base64编码,因为在eleFile中是无法获取到的。在reader获取到base64编码后,再触发img方法,此时即可获取到上传图片的原始尺寸。

const reader = new FileReader();
const img = new Image();
const eleFile = document.getElementById("file");

// 文件base64化,获取图片原始尺寸
img.onload = (image) => {
  originWidth = +image.path[0].width;
  originHeight = +image.path[0].height;
};
// 赋值图片信息给img
reader.onload = function(e) {
  img.src = e.target.result;
};
// 读取原始文件信息
eleFile.addEventListener('change', (event) => {
  reader.readAsDataURL(event.target.files[0]);
});

通过这三步即可获取图片的原始尺寸。

那么最后剩下的就是canvas和下载链接了,这没啥好说的,代码如下所示:

const canvas = document.getElementById('canvas')
const context = canvas.getContext('2d');
const eleLink = document.createElement('a');
eleLink.style.display = 'none';

因为下载链接的展示毫无意义,所以直接隐藏就好,下载这里可能会有点问题,不过不影响使用,后面会有详细解释。

准备工作还有一步就是上传文件input的触发,为了样式方便,这里将该input隐藏,要想触发只能通过click事件,所以需要新建一个触发点击的方法,之后再上传按钮上绑定该方法,即可完美触发,代码如下:

// HTML
<button class="btn m-b-10" onClick="showFileUpload()">上传图片</button>

// JS
const showFileUpload = () => {
  document.getElementById('file').click();
}

至此,举例项目前期的准备工作都已经完成了,下面开发项目的主体功能部分。

图片的压缩与预览

在压缩图片方法的一开始,应该先获取的用户输入的自定义尺寸信息。使用document.getElementById()方法即可。

之后进行目标尺寸的计算。此处的逻辑是如果用户只输入款宽高中的一个是,自动很足图片原始比例等比计算出另一半的长度,也就是说会保持比例不变进行缩放。

那么这是涉及到4种情况:

  1. 用户把自定义宽高都填了。这种情况直接将input值直接赋值给customWidthcustomHeight变量即可。

  2. 用户只填写了自定义宽度,高度没填。这种情况用原始尺寸计算出长宽比,之后乘上自定义宽度,即可得出对应的高度。

  3. 用户只填写了自定义高度,宽度没填。这种情况同上,计算出对应宽度即可

  4. 用户啥都没填,这就简单了,直接将原始宽高赋值给自定义宽高即可,就也是将originWidth赋值给targetWidth,将originHeight赋值给targetHeight

解决完这四种情况后我们即可拿到最终的目标宽高,此处用到的宽高共有三种,为了防止混淆,下面一一解释:

  1. originWidth/originHeight:用来存储图片的原生宽高,在img.onload中进行更新,每次上传图片都会触发更新。

  2. customWidth/customHeight:用来存储用户输入的宽高,此变量只在压缩图片方法中才会用到,在其内部直接使用document.getElementById()获取即可。

  3. targetWidth/targetHeight:用来确定压缩是的尺寸,因为用户有时输入的信息不全,可能会出现上面的4种情况,所以需要计算得出最终长宽。

const customWidth = +document.getElementById('custom-width').value;
const customHeight = +document.getElementById('custom-height').value;
//  判断宽高填写的四种情况
if (customWidth && customHeight) {
  targetWidth = customWidth;
  targetHeight = customHeight;
} else if (customWidth && !customHeight) {
  targetWidth = customWidth;
  targetHeight = Math.round(targetWidth * (originHeight / originWidth));
} else if (!customWidth && customHeight) {
  targetHeight = customHeight;
  targetWidth = Math.round(targetHeight * (originHeight / originWidth));
} else {
  targetWidth = originWidth;
  targetHeight = originHeight
}

Ok,现在图片的宽高已经完全弄清楚了,先更新下画布大小,将其改为targetWidth/targetHeight,否则图片无法整体压缩。

下面使用context进行图片的绘制,context是由canvasgetContext('2d')得到的一块画布,首先使用context.clearRect(0, 0, targetWidth, targetHeight)清空画布,上面0参数的作用是定位画布的起点,两个0的意思就是从画布的左上角开始,有点类似于绝对定位中的位置,之后的targetWidth/targetHeight参数是确定应该清空画布的长宽,从而确定整张画布的大小。

下面使用context.drawImage(img, 0, 0, targetWidth, targetHeight)方法来进行图片的绘制,第一个参数就是上文提到的img部件,里面存储着图片的base64编码,第二个剩下的参数和context.getContext()方法中的参数一样,在此不多做赘述。

下面就是最后一步了,使用canvastoDataURL()方法得到压缩后图片的base64编码。改方法接收两个参数,第一个是压缩后的图片类型,比方说image/pngimage/jpeg等,第二个参数就是图片的质量,是一个从0到1的小数,数字越大越清晰,此处可以从上面的<input type='range'>中拿到改变量。

// canvas对图片进行缩放
canvas.width = targetWidth;
canvas.height = targetHeight;
// 清除画布
context.clearRect(0, 0, targetWidth, targetHeight);
// 图片压缩
context.drawImage(img, 0, 0, targetWidth, targetHeight);
// 存储图片base64链接
let imgCompressed = '';
if (imgType === 'png') {
  imgCompressed = canvas.toDataURL('image/png');
} else {
  imgCompressed = canvas.toDataURL('image/jpeg', +document.getElementById('clarity').value / 100);
}
// 图片预览
document.getElementById('img-display').setAttribute('src', imgCompressed);
// 图片信息展示
imgInfo = `图片尺寸:${targetWidth} * ${targetHeight} (长 * 宽)(单位:像素)`;
document.getElementById('img-size').innerHTML = imgInfo;
document.getElementsByClassName('img-details')[0].classList.remove('hidden');
document.getElementsByClassName('img-preview')[0].classList.remove('hidden');

在上面的代码中,完成图片压缩后将图片的base64编码放到了img标签中,用来展示压缩后的文件,同时将预览和下载按钮展示出来,方面用户查阅。

至此,图片压缩部分已经完成了,剩下的就只有最后的图片下载功能了。

图片的下载与体积的计算

图片下载的原理是新建一个<a>标签,之后将压缩后的图片base64编码放到<a>href属性中,之后将<a>添加到页面中,触发其click事件,再将其删掉,即可完成此次下载。

这样做的问题是可能会有的朋友觉得很麻烦,因为增加和删除元素的操作有点费劲。其实也还好,感觉这样做省去了直接在页面上增加<a>标签,结构上会更整洁些。

代码实现如下所示:

const eleLink = document.createElement('a');
eleLink.style.display = 'none';
//  确定下载链接
eleLink.href = imgCompressed;
//  确定下载文件名
eleLink.download = `${targetWidth}_${targetHeight}`;
//  下载文件方法
const downloadImage = () => {
  //  添加元素
  document.body.appendChild(eleLink);
  //  触发点击
  eleLink.click();
  //  然后移除
  document.body.removeChild(eleLink);
}

文件体积的计算在上面的原理部分已经将的很详细了,这里只需反向思考即可得出文件的大小,不过这里没有根据windowsMAC系统的不同来修改进制,统一是1024的进制,有兴趣的同学可以改进下。代码如下:

const getFileSize = (base64Url) => {
  //  去掉无用头部信息(data:image/png;base64,)
  let baseStr=base64Url.substring(base64Url.indexOf('base64,')+'base64,'.length);
  //  使用正则去掉”=“
  baseStr = baseStr.replace(/=/gi, '');
  //  进行计算
  const strLen=baseStr.length;
  return strLen-(strLen/8)*2
}

之后我们增加以下文件体积的展示:

const updateImage = () => {
  绘制图片内容...
  // 存储图片base64链接
  if (imgType === 'png') {
    eleLink.href = canvas.toDataURL('image/png');
  } else {
    eleLink.href = canvas.toDataURL('image/jpeg', +document.getElementById('clarity').value / 100);
  }
  // 存储下载文件名
  eleLink.download = `${targetWidth}_${targetHeight}`;
  // 图片信息展示
  imgInfo = `图片尺寸:${targetWidth} * ${targetHeight} (长 * 宽)(单位:像素)`;
  document.getElementById('img-size').innerHTML = imgInfo;
  其他内容...
}

updateImage方法就是压缩图片的主要方法,在完成这个方法后,需要将其绑定在所有相关图片配置的选项上,如此一修改配置,用户即可看见预览图的变化,举个例子:

<ul>
  <li class="m-b-10">
    <span>宽度:</span>
    <input class="input-text" type="text" id="custom-width" placeholder="默认值为图片原始尺寸" onChange="updateImage()">
  </li>
  <li>
    <span>高度:</span>
    <input class="input-text" type="text" id="custom-height" placeholder="默认值为图片原始尺寸" onChange="updateImage()">
  </li>
</ul>

剩下的还有导出图片类型和图形质量选项,依次绑定即可。

其他

剩下的部分就是在线图片的获取了,这里的方法十分简单,将图片地址赋值给img部件即可,代码如下:

//  获取在线图片地址
const getOnlineImage = (value) => {
  img.src = value;
}

修改imgsrc属性会自动触发img.onload方法,之后也会顺其自然的根据当前配置来进行图片的压缩。

需要注意的一点就是有的图片可能会跨域,此时需要给img部件增加一个参数,如下:

//  开启图片地址的跨域
img.setAttribute("crossOrigin",'Anonymous')

如此即可简单的解决跨域问题,当然了,有些图片这样可能还是获取不了,这是就需要更加强大的功能来实现了,笔者这里有点懒,就先这样凑合了,以后有时间再改吧。

还有一点就是<input type='file'>元素会出现相同文件上传不变化的问题,这就有点尴尬了,可以将eleFile部件的value属性置空,让它以为自己目前没有图片,这样再次上传相同文件也就没有问题了。代码放在了updateImgae方法压缩完成功能的后面:

const updateImage = () => {
  其他内容...
  // 存储下载文件名
  eleLink.download = `${targetWidth}_${targetHeight}`;
  // 清除当前文件的路径值,避免不能上传同一张图片的问题
  eleFile.value = '';
  其他内容...
}

总结

好啦,到了这里整个项目也就完成了,难度始终,好好去研究的话问题不大。需求的确认比较关键,笔者写的时候使用的事“渐进增强”的方法,逐步增加更多的功能,到了后面会发现之前的代码很多都没有用,这就比较浪费时间了,所以希望各位读者可以吸取笔者的教训,开始开发之前一定要想好需求,否则真的会浪费很多时间的。

看了这么久,辛苦了,诸位!

PS

张鑫旭大佬的两篇文章:
www.zhangxinxu.com/wordpress/2…
www.zhangxinxu.com/wordpress/2…