利用canvas进行图片文件压缩

背景

对于大尺寸图片的上传,在前端进行压缩除了省流量外,最大的意义是极大的提高了用户体验。

这种体验包括两方面:

  1. 由于上传图片尺寸比较小,因此上传速度会比较快,交互会更加流畅,同时大大降低了网络异常导致上传失败风险。
  2. 最最重要的体验改进点:省略了图片的再加工成本。很多网站的图片上传功能都会对图片的大小进行限制,尤其是头像上传,限制5M或者2M以内是非常常见的。然后现在的数码设备拍摄功能都非常出众,一张原始图片超过2M几乎是标配,此时如果用户想把手机或相机中的某个得意图片上传作为自己的头像,就会遇到因为图片大小限制而不能上传的窘境,不得不对图片进行再处理,而这种体验其实非常不好的。如果可以在前端进行压缩,则理论上对图片尺寸的限制是没有必要的。

技术储备

canvas

  • canvas元素可以用来绘制图形以及图形动画

  • canvas暴露HTMLCanvasElement接口,包含了操作canvas元素布局和呈现的方法和属性,其中canvas.drawImage可以在canvas上绘制图片或者图片的一部分

  • 可以通过HTMLCanvasElement.getContext(“2d”)获取canvas上下文进行布局,然后通过canvas.toDataURL()返回:data 的URL,或者通过canvas.toBlob()导出一个Blob文件

  • HTMLCanvasElementCanvas教程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function test() {
     var canvas = document.getElementById("canvas");
     var url = canvas.toDataURL();
      //canvas.toBlob(function(blob){...}, "image/jpeg", 0.95); // JPEG 质量为95%
       
     var newImg = document.createElement("img");
     newImg.src = url;
     document.body.appendChild(newImg);
    }
    

blob

  • Binary Large Object:二进制数据的大对象;原始数据,无法变更
  • 使用字符串,或者对象创建blob,处理方式不同,用对象创建blob对象,会调用普通对象的toString()方法得到字符串数据再去创建blob对象
  • 无法对blob对象进行编辑改变,但可以通过slice方法对blob进行分割,生成一个新的blob对象
  • 使用场景有分片上传,以及BLob URL作为图片地址或者资源下载地址
  • blob 应用

url.createObjectURL

  • URL接口有两个方法,URL.createObjectURL 创建一个DOMString,包含一个唯一的blob链接, URL.invokeObjectURL 销毁通过createObjectURL创建的URL实例,可以动态创建链接a进行blob下载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let link = document.createElement('a');
link.download = 'hello.txt';

let blob = new Blob(['Hello, world!'], {type: 'text/plain'});

link.href = URL.createObjectURL(blob);
//URL.createObjectURL 取一个 Blob,并为其创建一个唯一的 URL,形式为 blob:<origin>/<uuid>
//link.href = "blob:https://javascript.info/1e67e00e-860d-40a5-89ae-6ab0cbee6273"
//浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 很短,但可以访问 Blob。
link.click();

URL.revokeObjectURL(link.href);
//生成的 URL(即其链接)仅在当前文档打开的状态下才有效。它允许引用 <img>、<a> 中的 Blob,以及基本上任何其他期望 URL 的对象。

//不过它有个副作用。虽然这里有 Blob 的映射,但 Blob 本身只保存在内存中的。浏览器无法释放它。

//应用程序寿命很长,那这个释放就不会很快发生。

//因此,如果我们创建一个 URL,那么即使我们不再需要该 Blob 了,它也会被挂在内存中。

//URL.revokeObjectURL(url) 从内部映射中移除引用,因此允许 Blob 被删除(如果没有其他引用的话),并释放内存

FileReader

  • fileReader对象允许web内置应用程序读取用户计算机中文件内容,或者原始数据缓冲区中的内容,已File 或者Blob的数据格式读取
  • 不能直接通过路径读取,需要使用ajax解决方案进行服务器端文件读取
  • 可以通过FileReader.readAsArrayBuffer,readerAsDataURL,readerAsBinaryString,readerAsText等方法读取Blob中内容再以不同形式进行输出

File

  • file对象是一种特殊格式的blob,通常,是用户通过input标签选择文件后返回的FileLIst对象,以及其他如DataTransfer对象;
  • 可以把Blob文件生成File文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var aBlob = new Blob(array, options );
var aFile = new Blob(bits,name,options); 
//bits :一个包含ArrayBuffer,ArrayBufferView,Blob,或者 DOMString 对象的 Array — 或者任何这些对象的组合。这是 UTF-8 编码的文件内容。

var aFileParts = ['<a id="a"><b id="b">hey!</b></a>']; // 一个包含DOMString的数组
var oMyBlob = new Blob(aFileParts, {type : 'text/html'}); // 得到 blob
var file = new File(["foo"], "foo.txt", {
  type: "text/plain",
});

//把Blob文件生成File文件,File最主要是比blob多了一个名字
let blobToFile = function(theBlob, fileName) {
      return new File([theBlob], fileName);
      //var file2 = new File([blob], 'test.png', {type: 'image/png'});
}

文件压缩思路

利用HTML5 File API加上canvas实现前端图片JS压缩功能,核心是使用canvas的drawImage方法

1
2
3
context.drawImage(img, dx, dy);
context.drawImage(img, dx, dy, dWidth, dHeight);
context.drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
  • img

    就是图片对象,可以是页面上获取的DOM对象,也可以是虚拟DOM中的图片对象。

  • dx, dy, dWidth, dHeight

    表示在canvas画布上规划处一片区域用来放置图片,dx, dy为canvas元素的左上角坐标,dWidth, dHeight指canvas元素上用在显示图片的区域大小。如果没有指定sx,sy,sWidth,sHeight这4个参数,则图片会被拉伸或缩放在这片区域内。

  • sx,sy,swidth,sheight

    这4个坐标是针对图片元素的,表示图片在canvas画布上显示的大小和位置。sx,sy表示图片上sx,sy这个坐标作为左上角,然后往右下角的swidth,sheight尺寸范围图片作为最终在canvas上显示的图片内容。

drawImage()方法有一个非常怪异的地方,大家一定要注意,那就是5参数和9参数里面参数位置是不一样的,这个和一般的API有所不同。一般API可选参数是放在后面。但是,这里的drawImage()9个参数时候,可选参数sx,sy,swidth,sheight是在前面的。如果不注意这一点,有些表现会让你无法理解。

Canvas drawimage()原理示意

对于本文的图片压缩,需要用的是是5个参数语法。举个例子,一张图片(假设图片对象是img)的原始尺寸是40003000,现在需要把尺寸限制为400300大小,很简单,原理如下代码示意:

1
2
3
4
5
6
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
canvas.width = 400;
canvas.height = 300;
// 核心JS就这个
context.drawImage(img,0,0,400,300);

把一张大的图片,直接画在一张小小的画布上。此时大图片就天然变成了小图片,压缩就这么实现了,是不是简单的有点超乎想象。

实践思路

重点要解决图片来源和图片去向的问题

1. 如何把系统中图片呈现在浏览器中?

HTML5 file API可以让图片在上传之前直接在浏览器中显示,通常使用FileReader方法,代码示意如下:

1
2
3
4
5
6
7
8
9
var reader = new FileReader(), img = new Image();
// 读文件成功的回调
reader.onload = function(e) {
  // e.target.result就是图片的base64地址信息
  img.src = e.target.result;
};
eleFile.addEventListener('change', function (event) {
    reader.readAsDataURL(event.target.files[0]);
});

于是,包含图片信息的context.drawImage()方法中的img图片就有了。

2. 如果把canvas画布转换成img图像

canvas天然提供了2个转图片的方法,一个是:

  • canvas.toDataURL()方法

    语法如下:canvas.toDataURL(mimeType, qualityArgument)可以把图片转换成base64格式信息,纯字符的图片表示法。其中: mimeType表示canvas导出来的base64图片的类型,默认是png格式,也即是默认值是'image/png',我们也可以指定为jpg格式'image/jpeg'或者webp等格式。file对象中的file.type就是文件的mimeType类型,在转换时候正好可以直接拿来用(如果有file对象)。 qualityArgument表示导出的图片质量,只要导出为jpgwebp格式的时候此参数才有效果,默认值是0.92,是一个比较合理的图片质量输出参数,通常情况下,我们无需再设定。

  • canvas.toBlob()方法

    语法如下:canvas.toBlob(callback, mimeType, qualityArgument)可以把canvas转换成Blob文件,通常用在文件上传中,因为是二进制的,对后端更加友好。和toDataURL()方法相比,toBlob()方法是异步的,因此多了个callback参数,这个callback回调方法默认的第一个参数就是转换好的blob文件信息,本文demo的文件上传就是将canvas图片转换成二进制的blob文件,然后再ajax上传的,代码如下:// canvas转为blob并上传 canvas.toBlob(function (blob) { // 图片ajax上传 var xhr = new XMLHttpRequest(); // 开始上传 xhr.open("POST", 'upload.php', true); xhr.send(blob); });

于是,经过“图片→canvas压缩→图片”三步曲,我们完成了图片前端压缩并上传的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
HTML代码:
<input id="file" type="file">
JS代码:
var eleFile = document.querySelector('#file');

// 压缩图片需要的一些元素和对象
var reader = new FileReader(), img = new Image();

// 选择的文件对象
var file = null;

// 缩放图片需要的canvas
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');

// base64地址图片加载完毕后
img.onload = function () {
    // 图片原始尺寸
    var originWidth = this.width;
    var originHeight = this.height;
    // 最大尺寸限制
    var maxWidth = 400, maxHeight = 400;
    // 目标尺寸
    var targetWidth = originWidth, targetHeight = originHeight;
    // 图片尺寸超过400x400的限制
    if (originWidth > maxWidth || originHeight > maxHeight) {
        if (originWidth / originHeight > maxWidth / maxHeight) {
            // 更宽,按照宽度限定尺寸
            targetWidth = maxWidth;
            targetHeight = Math.round(maxWidth * (originHeight / originWidth));
        } else {
            targetHeight = maxHeight;
            targetWidth = Math.round(maxHeight * (originWidth / originHeight));
        }
    }
        
    // canvas对图片进行缩放
    canvas.width = targetWidth;
    canvas.height = targetHeight;
    // 清除画布
    context.clearRect(0, 0, targetWidth, targetHeight);
    // 图片压缩
    context.drawImage(img, 0, 0, targetWidth, targetHeight);
    // canvas转为blob并上传
    canvas.toBlob(function (blob) {
        // 图片ajax上传
        var xhr = new XMLHttpRequest();
        // 文件上传成功
        xhr.onreadystatechange = function() {
            if (xhr.status == 200) {
                // xhr.responseText就是返回的数据
            }
        };
        // 开始上传
        xhr.open("POST", 'upload.php', true);
        xhr.send(blob);    
    }, file.type || 'image/png');
};

// 文件base64化,以便获知图片原始尺寸
reader.onload = function(e) {
    img.src = e.target.result;
};
eleFile.addEventListener('change', function (event) {
    file = event.target.files[0];
    // 选择的文件是图片
    if (file.type.indexOf("image") == 0) {
        reader.readAsDataURL(file);    
    }
});

CompressorJS源码解读

compressorJS源码

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<input type="file" id="file" accept="image/*">
  
import axios from 'axios';
import Compressor from 'compressorjs';
 
document.getElementById('file').addEventListener('change', (e) => {
  const file = e.target.files[0];
 
  if (!file) {
    return;
  }
 
  new Compressor(file, {
    quality: 0.6,
    maxHeight:526,
    minHeight:216,
    success(result) {
      const formData = new FormData();
 
      // The third parameter is required for server
      formData.append('file', result, result.name);
 
      // Send the compressed image file to server with XMLHttpRequest.
      axios.post('/path/to/upload', formData).then(() => {
        console.log('Upload success');
      });
    },
    error(err) {
      console.log(err.message);
    },
  });
});

重点代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * Transform array buffer to Data URL.
 * @param {ArrayBuffer} arrayBuffer - The array buffer to transform.
 * @param {string} mimeType - The mime type of the Data URL.
 * @returns {string} The result Data URL.
 */
export function arrayBufferToDataURL(arrayBuffer, mimeType) {
  const chunks = [];
  const chunkSize = 8192;
  let uint8 = new Uint8Array(arrayBuffer);

  while (uint8.length > 0) {
    // XXX: Babel's `toConsumableArray` helper will throw error in IE or Safari 9
    // eslint-disable-next-line prefer-spread
    chunks.push(fromCharCode.apply(null, toArray(uint8.subarray(0, chunkSize))));
    uint8 = uint8.subarray(chunkSize);
  }

  return `data:${mimeType};base64,${btoa(chunks.join(''))}`;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
 init() {
    const { file, options } = this;

   	//压缩文件 为Blob格式
    if (!isBlob(file)) {
      this.fail(new Error('The first argument must be a File or Blob object.'));
      return;
    }
		
    //获取文件type值
    const mimeType = file.type;
		//判断是否是图片类型
    if (!isImageType(mimeType)) {
      this.fail(new Error('The first argument must be an image File or Blob object.'));
      return;
    }
		//通用处理文件URL,FileReader的属性是否存在
    if (!URL || !FileReader) {
      this.fail(new Error('The current browser does not support image compression.'));
      return;
    }
    
    if (!ArrayBuffer) {
      options.checkOrientation = false;
    }

    if (URL && !options.checkOrientation) {
      this.load({
        url: URL.createObjectURL(file),
      });
    } else {
      const reader = new FileReader();
      const checkOrientation = options.checkOrientation && mimeType === 'image/jpeg';

      this.reader = reader;
      reader.onload = ({ target }) => {
        const { result } = target;
        const data = {};

        if (checkOrientation) {
          // Reset the orientation value to its default value 1
          // as some iOS browsers will render image with its orientation
          const orientation = resetAndGetOrientation(result);

          if (orientation > 1 || !URL) {
            // Generate a new URL which has the default orientation value
            data.url = arrayBufferToDataURL(result, mimeType);

            if (orientation > 1) {
              Object.assign(data, parseOrientation(orientation));
            }
          } else {
            data.url = URL.createObjectURL(file);
          }
        } else {
          data.url = result;
        }

        this.load(data);
      };
      reader.onabort = () => {
        this.fail(new Error('Aborted to read the image with FileReader.'));
      };
      reader.onerror = () => {
        this.fail(new Error('Failed to read the image with FileReader.'));
      };
      reader.onloadend = () => {
        this.reader = null;
      };

      if (checkOrientation) {
        reader.readAsArrayBuffer(file);
      } else {
        reader.readAsDataURL(file);
      }
    }
  }

  load(data) {
    const { file, image } = this;

    image.onload = () => {
      this.draw({
        ...data,
        naturalWidth: image.naturalWidth,
        naturalHeight: image.naturalHeight,
      });
    };
    image.onabort = () => {
      this.fail(new Error('Aborted to load the image.'));
    };
    image.onerror = () => {
      this.fail(new Error('Failed to load the image.'));
    };

    // Match all browsers that use WebKit as the layout engine in iOS devices,
    // such as Safari for iOS, Chrome for iOS, and in-app browsers.
    if (WINDOW.navigator && /(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(WINDOW.navigator.userAgent)) {
      // Fix the `The operation is insecure` error (#57)
      image.crossOrigin = 'anonymous';
    }

    image.alt = file.name;
    image.src = data.url;
  }

  draw({
    naturalWidth,
    naturalHeight,
    rotate = 0,
    scaleX = 1,
    scaleY = 1,
  }) {
    const { file, image, options } = this;
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    const aspectRatio = naturalWidth / naturalHeight;
    const is90DegreesRotated = Math.abs(rotate) % 180 === 90;
    let maxWidth = Math.max(options.maxWidth, 0) || Infinity;
    let maxHeight = Math.max(options.maxHeight, 0) || Infinity;
    let minWidth = Math.max(options.minWidth, 0) || 0;
    let minHeight = Math.max(options.minHeight, 0) || 0;
    let width = Math.max(options.width, 0) || naturalWidth;
    let height = Math.max(options.height, 0) || naturalHeight;

    if (is90DegreesRotated) {
      [maxWidth, maxHeight] = [maxHeight, maxWidth];
      [minWidth, minHeight] = [minHeight, minWidth];
      [width, height] = [height, width];
    }

    if (maxWidth < Infinity && maxHeight < Infinity) {
      if (maxHeight * aspectRatio > maxWidth) {
        maxHeight = maxWidth / aspectRatio;
      } else {
        maxWidth = maxHeight * aspectRatio;
      }
    } else if (maxWidth < Infinity) {
      maxHeight = maxWidth / aspectRatio;
    } else if (maxHeight < Infinity) {
      maxWidth = maxHeight * aspectRatio;
    }

    if (minWidth > 0 && minHeight > 0) {
      if (minHeight * aspectRatio > minWidth) {
        minHeight = minWidth / aspectRatio;
      } else {
        minWidth = minHeight * aspectRatio;
      }
    } else if (minWidth > 0) {
      minHeight = minWidth / aspectRatio;
    } else if (minHeight > 0) {
      minWidth = minHeight * aspectRatio;
    }

    if (height * aspectRatio > width) {
      height = width / aspectRatio;
    } else {
      width = height * aspectRatio;
    }

    width = Math.floor(normalizeDecimalNumber(Math.min(Math.max(width, minWidth), maxWidth)));
    height = Math.floor(normalizeDecimalNumber(Math.min(Math.max(height, minHeight), maxHeight)));

    const destX = -width / 2;
    const destY = -height / 2;
    const destWidth = width;
    const destHeight = height;

    if (is90DegreesRotated) {
      [width, height] = [height, width];
    }

    canvas.width = width;
    canvas.height = height;

    if (!isImageType(options.mimeType)) {
      options.mimeType = file.type;
    }

    let fillStyle = 'transparent';

    // Converts PNG files over the `convertSize` to JPEGs.
    if (file.size > options.convertSize && options.mimeType === 'image/png') {
      fillStyle = '#fff';
      options.mimeType = 'image/jpeg';
    }

    // Override the default fill color (#000, black)
    context.fillStyle = fillStyle;
    context.fillRect(0, 0, width, height);

    if (options.beforeDraw) {
      options.beforeDraw.call(this, context, canvas);
    }

    if (this.aborted) {
      return;
    }

    context.save();
    context.translate(width / 2, height / 2);
    context.rotate((rotate * Math.PI) / 180);
    context.scale(scaleX, scaleY);
    context.drawImage(image, destX, destY, destWidth, destHeight);
    context.restore();

    if (options.drew) {
      options.drew.call(this, context, canvas);
    }

    if (this.aborted) {
      return;
    }

    const done = (result) => {
      if (!this.aborted) {
        this.done({
          naturalWidth,
          naturalHeight,
          result,
        });
      }
    };

    if (canvas.toBlob) {
      canvas.toBlob(done, options.mimeType, options.quality);
    } else {
      done(toBlob(canvas.toDataURL(options.mimeType, options.quality)));
    }
  }

  done({
    naturalWidth,
    naturalHeight,
    result,
  }) {
    const { file, image, options } = this;

    if (URL && !options.checkOrientation) {
      URL.revokeObjectURL(image.src);
    }

    if (result) {
      // Returns original file if the result is greater than it and without size related options
      if (options.strict && result.size > file.size && options.mimeType === file.type && !(
        options.width > naturalWidth
        || options.height > naturalHeight
        || options.minWidth > naturalWidth
        || options.minHeight > naturalHeight
      )) {
        result = file;
      } else {
        const date = new Date();

        result.lastModified = date.getTime();
        result.lastModifiedDate = date;
        result.name = file.name;

        // Convert the extension to match its type
        if (result.name && result.type !== file.type) {
          result.name = result.name.replace(
            REGEXP_EXTENSION,
            imageTypeToExtension(result.type),
          );
        }
      }
    } else {
      // Returns original file if the result is null in some cases.
      result = file;
    }

    this.result = result;

    if (options.success) {
      options.success.call(this, result);
    }
  }

  fail(err) {
    const { options } = this;

    if (options.error) {
      options.error.call(this, err);
    } else {
      throw err;
    }
  }

  abort() {
    if (!this.aborted) {
      this.aborted = true;

      if (this.reader) {
        this.reader.abort();
      } else if (!this.image.complete) {
        this.image.onload = null;
        this.image.onabort();
      } else {
        this.fail(new Error('The compression process has been aborted.'));
      }
    }

结束语

图片压缩其实和图片合成实际上所使用的技术和套路和本文是如出一辙的,也是“图片→canvas水印→图片”三步曲,区别在于水印合成是连续执行两次context.drawImage()方法,一次是原图一次水印图片,以及最后转换成图片的时候什么是toDataURL()方法,其他代码逻辑和原理都是一样的。

由此及彼,利用同样的原理和代码逻辑,我们还可以实现其它很多以前前端不太好实现的功能,比方说图片的真剪裁效果,所谓“真剪裁”指不是使用个overflow:hidden或者clip这些CSS属性的“伪剪裁”,而是真正意义上就这么大区域图片信息。甚至配合一些前端算法,我们可以直接在前端进行人脸识别,图片自动美化等一系列功能再上传等等。

原理都是一样的,都是利用canvas作为中间媒介进行处理。