用原生js手写前端图片压缩上传插件

3,773 阅读10分钟

前端上传图片已经是一个老生常谈的话题了,本人也在工作中多次遇到这类需求,虽然现在市面上现有的插件也很多,但是,本着自己造轮子的想法,打算自己搞一波,也是对相关知识的一个总结和梳理。

整体体思路

  • 利用type='file' input标签来获取图片的files对象。
  • 将files对象通过FileReader来转化为base64格式。
  • 通过canvas.drawImage()对图片进行压缩处理。
  • 将base64 通过Exift.js插件进行图片校验,将发生旋转的图片通过canvas进行矫正。
  • 将base64转为blob二进制对象,进行上传。

01.什么是files对象?

通过以下简单的代码,我们可以获取一个fileList的类数组

类数组: 1)拥有length属性,其它属性(索引)为非负整数(对象中的索引会被当做字符串来处理); 2)不具有数组所具有的方法!(如:push,forEach,filter,reduce等等)

...
<input type="file" @change="handleChange"/>
...
...
handleChange(e){
    console.log(e.target.files)
}
...

得到以下对象:

主要的属性有:

  • name:图片的名字
  • lastModified:文件修改的时间戳
  • size: 文件大小,单位:B
  • type: 文件的类型
  • 等等

我们会发现,里面记录了文件的名字,修改时间,大小和类型等,可知这个对象只存放了一些文件的信息,相当于是本地文件的索引,并不是把文件放到input中了,上传文件时它会再去找到实际的本地文件。注意这是一个只读对象!,所以不要尝试直接去修改它.

02.关于FileReader

前面说了,既然无法直接修改fileList对象,那如果我们需要对原始文件做处理,诸如压缩等操作,又该如何?这里我们可以使用FileReader.readAsDataURL(file),来获得URL格式的Base64字符串以表示所读取文件的内容。我们再通过处理base64,来达到我们的目的。

FileReader概念: FileReader 对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。

所以我们可以将刚才得到的fileList对象做如下处理

...
 if (files.length === 0) return;
    let file = files[0]
    console.log(file, 'file')
    let fileReader = new FileReader()
    fileReader.onloadstart = () => {
        // 如果类型不符合
        if (this.accept !== 'image/*' && !this.accept.includes(file.type.toLowerCase())) {
            fileReader.abort()
            console.error('格式错误了-->', file.type.toLowerCase())
          }
    }
    fileReader.onload = () => {
        // 这就是我们这一步需要的base64
          let base64 = fileReader.result
    }
    fileReader.onprogress = (e) => {}
    fileReader.readAsDataURL(file) 
...

03.压缩

前面通过解释fileList的概念,我们知道,文件都是有大小的(废话),我们可以通过 .size来获取源文件的尺寸。我们都知道,随着如今手机像素越来越高,图片越来越清晰,图片的尺寸也水涨船高,变得越来越大,如果在网速比较差的环境下,上传图片会变成这样:

此时,如何加速上传,成为了一个重点,目前常见的有诸如分片上传,好处是可以保证图片质量,监控进度。坏处是需要前后端同时配合处理。这里主要简单讲下如何压缩的思路: 我们可以将上一步中我们得到的base64通过canvas.drawImage()将图片绘制在canvas画布上,然后再通过canvas.toDataURL('image/jpeg', this.quality)来获取压缩后的base64。

canvas: html5新增的元素,可用于通过使用js的脚本来绘制图形,创建动画(相信大家都用过)。

代码如下:

...
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d');
let image = document.createElement('img');
image.src = base64;
image.onload = () => {
    canvas.width = imageWidth
    canvas.height = imageHeight
    ctx.drawImage(image, 0, 0, imageWidth, imageHeight);
     //质量压缩成之前的0.5
    canvas.toDataURL('image/jpeg', 0.5)
}
...

关于drawImage方法

我觉得这是个很强大的方法,不仅可以用来绘图,更重要的是还可用于前端图片的裁剪,由于后面的旋转画布也用到了这个方法,所以这里稍微展开下。

ctx.drawImage(img,startX,startY,croppedWidth,croppedHeight,locationX,LocationY,finalCanvasWidth,finalCanvasHeight),虽然参数看上去非常多,但是,其实也不难发现规律

在实践中发现,该方法实际上相当于有2中的模式,我们暂且就简单叫 5参数模式9参数的模式 具体参数说明:

  • img:也就是将被呈现到画布上的资源,可以是图片,也可以是视频。

  • startX/startY: 这个参数,个人理解有2种模式区别所在。

    5参数模式 表示图片放置在画布上的位置(canvas坐标);

    9参数模式 表示在图片上开始裁剪的位置(图片坐标)

  • croppedWidth/croppedHeight:在图片上裁剪的范围

  • locationX/locationY:在canvas画布上开始绘图的位置,实际上代替startX/startY在5参数模式下的角色

  • finalCanvasWidth/finalCanvasHeight:在canvas画布上绘图的范围(处理不当会造成图像变形扭曲)

所以,我们会发现,drawImage中,除了第一个参数外,其余参数都和坐标与长度相关,而且在9参数模式中,前2对长度入参(startX/startY和croppedWidth/croppedHeigh)是和被裁剪的图片相关,后两对长度入参(locationX/locationY和finalCanvasWidth/finalCanvasHeight),都和画布相关。

下面简单介绍下基本的使用:

  1. 我们可以忽略后面2对参数,也能绘制图片,但要处理好原图和画布的大小关系,canvas默认尺寸为300 * 150,如果我们找一张非常大的图片1280 * 853
...
<!--绘制整张原图到画布上-->
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight)
...

可以看出,我们虽然绘制出了图片,但却并不完整,因为我们相当于获取了整张图范围内的像素信息img.naturalWidth, img.naturalHeight,但却没有很好处理它,只是将它简单丢到300 * 150的矩形中,剩余部分一概不考虑。后果可想而知,我们只获取了左上角一点点图片,其他都没了,削足适履。

  1. 所以,本案例矛盾点在于我们的要放的图太大,而"容器"太小,所以解决办法只有2个,要么缩小图片,要么扩大容器。
 ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, canvas.width, canvas.height)

这次图片都显示出来了,但是,我们可以看出,第一,图片模糊了,第二,图片变形了; 为啥会变形?因为我们没考虑比例,没考虑原图的比例,只是压缩尺寸后强行"塞进"一个300*150的"容器"中,就像橡皮人一样变形。。

  1. 所以还要考虑图片长宽比例问题,我们计算原图的长宽比例img.naturalWidth / img.naturalHeight,再计算出"容器"本应有的尺寸
   let radio = img.naturalWidth / img.naturalHeight
   ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, (canvas.width - canvas.height * radio) / 2, 0, canvas.height * radio, canvas.height)

可以了,这下不变形了,但是还有一个问题,就是图片变得模糊了,原因是无论我们前2步怎么处理,其实都是在把图片"变小",并没有把"容器"变大。

  1. 所以按照将"容器"变大的想法,我们有了如下处理:
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight)

04.图片旋转问题

正当一切看似很顺利,提测之后测试同学传来'噩耗',图片旋转了 翻车现场:

  • 手机(android)竖拍

  • 手机(ios)竖拍

    经过简(反)单(复)测(折)试(磨)之后,发现是ios自身的bug导致的,总结如下:

  • 当用ios手机竖着拍摄的时候,照片会逆时针旋转90度;

  • 当用ios手机横向(home按键/手机底部在右边)拍摄的时候,照片正常;

  • 当用ios手机横向(home按键/手机底部在左边)拍摄的时候,照片会针旋转180度;

  • 当用ios手机倒着拍照(我知道一般人不会,除非你非正常拍摄。。)的时候,照片会顺时针旋转90度;

解决思路: 既然照片旋转了,那自然想到的就是矫正回来,就像既然得了近视,晶状体无法将光正确聚焦在视网膜上,我们就会用眼镜矫正光的角度。而不同的"度数"则对应不同矫正方式,而要矫正之前首先要知道是否出了问题,出的是哪种问题,我这里使用的检测插件是exif-js,利用getTag方法来获取图片旋转的度数,从而采取不同措施。

...
 const EXIF = require("exif-js");
 EXIF.getData(file, function () {
    let Orientation = EXIF.getTag(this, 'Orientation');
    // 通过Orientation的值来判断照片是否正常以及错误旋转的度数
    // Orientation === 6 :逆时针旋转了90度
    // Orientation === 3 :旋转180度
    // Orientation === 8 :顺时针旋转了90度
    // Orientation === 1 或者 undefined :正常
 })
 ...
  1. 照片逆时针旋转90度情况下:
...
<!--图片既然旋转90度,那么画布长宽肯定互换位置了-->
canvas.width = imageHeight;
canvas.height = imageWidth;
将图片顺时针旋转90度
ctx.rotate(Math.PI / 2);
<!--之前有说过drawImage有两种模式,这是5参数模式-->
ctx.drawImage(img, 0, -imageHeight, imageWidth, imageHeight);
<!--9参数模式-->
ctx.drawImage(img, 0, 0, imageWidth,imageHeight,  0, -imageHeight, imageWidth, imageHeight);
<!--其实可以看得出来9参数模式中,6,7号参数代替了5参数模式中2,3号参数的位置-->
...

2.照片旋转180度情况下:

...
<!--画布长宽,注意180度就不要互换了-->
canvas.width = imageWidth;
canvas.height = imageHeight;
<!--将画布再转180度-->
ctx.rotate(Math.PI);
<!--5参数模式-->
ctx.drawImage(img, -imageWidth, -imageHeight, imageWidth, imageHeight);
<!--9参数模式-->
ctx.drawImage(img, 0, 0, imageWidth,imageHeight,  -imageWidth, -imageHeight, imageWidth, imageHeight);
...

3.照片顺时针旋转了90度情况下:

...
<!--旋转90度,长宽互换-->
canvas.width = imageHeight;
canvas.height = imageWidth;
<!--将图片逆时针旋转90度-->
ctx.rotate(3 * Math.PI / 2);
<!--5参数模式-->
ctx.drawImage(img, -imageWidth, 0, imageWidth, imageHeight);
<!--9参数模式-->
ctx.drawImage(img, 0, 0, imageWidth,imageHeight,  -imageWidth, 0, imageWidth, imageHeight);
...

05.转为blob对象上传

经过了刚才一系列的步骤,我们拿到的仅仅是一个base64,我们还需要转为blob二进制数据然后上传到服务端:

let arr = base64.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
}
Blob([u8arr], { type: mime });
...

完整代码

好了,说了这么多,现在贴出完整代码如下:

class Upload {
  constructor({ el, accept, multiple, onUpload, quality }) {
    this.el = el || ''
    this.accept = accept || 'image/*'
    this.multiple = multiple || false
    this.quality = quality || 1
    this.beforeUpload = (e) => { console.log(e) }
    this.onProgress = (e) => { console.log('progress->', e) }
    this.onLoad = (result) => {
      onUpload(result)
    }
    this.onError = () => { }
    this.init()
  }
  init () {
    // 如果存在节点
    if (this.el) {
      this.el = typeof this.el === 'object' ? this.el : document.querySelector(this.el)
    }
    this.render()
    this.watch()
  }
  // 渲染节点
  render () {
    let fragment = document.createDocumentFragment(),
      file = document.createElement('input');
    console.log(file, 'file')
    file.type = this.accept
    file.setAttribute('type', 'file');
    file.setAttribute('multiple', this.multiple)
    file.setAttribute('accept', this.accept)
    // 安卓非微信浏览器
    // 苹果/安卓(小米手机)手机微信浏览器
    // file.setAttribute('capture', 'camera')
    file.style.display = "none"
    file.className = 'upload__input'
    fragment.appendChild(file)
    this.el.appendChild(fragment)
  }
  watch () {
    let inputEl = this.el.querySelector('.upload__input');
    inputEl.addEventListener('change', () => {
      // 伪数组转为数组
      let files = Array.from(inputEl.files) // 如同时选择几张图片,则该数组.length>1
      // 读取图片
      let readImg = () => {
        // 图片递归殆尽则终止
        if (files.length === 0) return;
        let file = files[0]
        let fileReader = new FileReader()
        fileReader.onloadstart = () => {
          // 如果类型不符合
          if (this.accept !== 'image/*' && !this.accept.includes(file.type.toLowerCase())) {
            fileReader.abort()
            this.beforeUpload(file)
            console.error('文件格式有误-->', file.type.toLowerCase())
          }
        }
        fileReader.onload = async () => {
          let base64 = fileReader.result
          let compressedBase64 = await this.compressBase64(file, base64)
          let blob = this.base64ToBlob(compressedBase64)
          this.onLoad({ blob, base64: compressedBase64 })
          files.shift() // 删除第一个
          // 递归
          readImg()
        }
        fileReader.onprogress = (e) => {
          this.onProgress(e)
        }
        this.isImage(file.type) ? fileReader.readAsDataURL(file) : fileReader.readAsText(file);
      }
      readImg()
    })
  }

  // 压缩base64
  compressBase64 (file, base64) {
    let canvas = document.createElement('canvas')
    let image = document.createElement('img');
    image.src = base64;
    let size = file.size / 1000 / 1024 // b -> MB

    console.log(size, 'MB')
    this.quality = Math.min(2 / size, 1) // 图片大小限制为2MB以内
    console.log(this.quality, 'quality')
    return new Promise(resolve => {
      image.onload = async () => {
        let imageWidth = image.naturalWidth;
        let imageHeight = image.naturalHeight;
        await this.rotateCanvas(file, image, canvas, imageWidth, imageHeight)
        resolve(canvas.toDataURL('image/jpeg', this.quality))
      }
    })
  }

  // 旋转画布,防止ios低版本图片旋转问题
  rotateCanvas (file, image, canvas, imageWidth, imageHeight) {
    let ctx = canvas.getContext('2d');
    let Orientation = 1
    const EXIF = require("exif-js");
    return new Promise(resolve => {
      EXIF.getData(file, function () {
        // 获取图片信息
        Orientation = EXIF.getTag(this, 'Orientation');
        console.log(Orientation, 'orient')
        switch (Orientation * 1) {
          case 6:     // 旋转90度
            canvas.width = imageHeight;
            canvas.height = imageWidth;
            ctx.rotate(Math.PI / 2);
            ctx.fillStyle = "white"
            ctx.fillRect(0, 0, canvas.width, canvas.height)
            ctx.drawImage(image, 0, -imageHeight, imageWidth, imageHeight);
            break;
          case 3:// 旋转180度
            canvas.width = imageWidth;
            canvas.height = imageHeight;
            ctx.rotate(Math.PI);
            ctx.fillStyle = "white"
            ctx.fillRect(0, 0, canvas.width, canvas.height)
            ctx.drawImage(image, -imageWidth, -imageHeight, imageWidth, imageHeight);
            break;
          case 8:     // 旋转-90度
            canvas.width = imageHeight;
            canvas.height = imageWidth;
            ctx.rotate(3 * Math.PI / 2);
            ctx.fillStyle = "white"
            ctx.fillRect(0, 0, canvas.width, canvas.height)
            ctx.drawImage(image, -imageWidth, 0, imageWidth, imageHeight);
            break;
          default:
            // 默认正确的情况下
            canvas.width = imageWidth;
            canvas.height = imageHeight;
            ctx.fillStyle = "white"
            ctx.fillRect(0, 0, canvas.width, canvas.height)
            ctx.drawImage(image, 0, 0, imageWidth, imageHeight);
        }
        resolve()
      });
    })
  }
  // 检验是否为图片
  isImage (type) {
    let reg = /(image\/jpeg|image\/jpg|image\/png)/gi
    return reg.test(type)
  }
  // base64 -> blob
  base64ToBlob (base64) {
    let arr = base64.split(','),
      mime = arr[0].match(/:(.*?);/)[1],
      bstr = atob(arr[1]),
      n = bstr.length,
      u8arr = new Uint8Array(n);
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }
}

export default Upload

调用

    new Upload({
      el: document.querySelector('.upload__btn'),
      accept: 'image/*',
      multiple: true,
      quality: 1,
      onUpload ({ blob, base64 }) {
        // base64 用于预览
        // blob 给后台
      }
    })