移动端图片压缩上传实现
移动端上传的图片一般都是手机照片,现在的手机都是高清像素,一张图片都在三四兆,直接上传不仅传输速度慢,而且如果用户使用的是流量,势必会耗费大量流量。
H5的各种API在移动端的主流浏览器都得到了很好的支持,比如案例中用到的FileReader、Blob、FormData、canvas等API,所以压缩上传图片在前端已经是必备的操作。
压缩上传基本操作流程:
- 图片上传后使用FileReader将文件读取成base64
- 创建Image,设置src属性为图片base64
- 创建canvas,绘制Image
- 调用canvas的toDataURL方法压缩,返回压缩后的base64
- 将base64转成Blob对象
- 创建FormData对象,append Blob对象,提交给服务端
下面是每一步的具体实现以及一些坑(比如:API的兼容性、IOS图片旋转、底色等),并贴上全部代码。
调用系统录制功能
调用系统相机
import EXIF from 'exif';
一、监听input的change事件,读取成base64。如果照片是竖着拍的,在IOS手机上传后图片会被旋转。这里需要用到一个库EXIF),可以获取相片的属性,比如曝光度、拍照方向、GPS等。图片加载完成后,在压缩前需要解决IOS图片是否被旋转的问题和图片压缩格式的问题。
-
图片旋转
- 问题: IOS竖着拍的照片会旋转。
- 解决: 首先创建临时canvas,绘制图片,旋转成正确方向
canvas的toDataURL() 参数type的默认值是 “image/png”,如果传入的类型非“image/png”,但是返回的值以“data:image/png”开头,那么该传入的类型是不支持的。把类型统一设成jpeg,也就是统一用canvas.toDataURL('image/jpeg', 0.3) ,压缩默认值 0.92,这里我设的0.3。
let inp = document.getElementById('upload');
inp.onchange = function (event) {
let file = event.target.files[0];
let reader = new FileReader();
let Orientation;
// 读取文件转base64
reader.readAsDataURL(file);
// 读取完成
reader.onload = function () {
let result = this.result;
/**
* result.length 的单位是字节
* 如果图片小于100K直接上传,反之压缩图片
*/
if (result.length <= (100 * 1024)) {
// 直接上传 调用API
}
else {
// 创建image
let image = new Image();
image.src = result;
// 图片加载完成
image.onload = function () {
//获取拍照的信息,解决IOS拍出来的照片旋转问题
EXIF.getData(image, function () {
Orientation = EXIF.getTag(this, 'Orientation');
});
// 首先旋转成正确位置 再根据大小压缩 然后根据像素判断是否需要通过瓦片绘制
let canvas;
// 修复ios拍照上传图片的时被旋转的问题
if (Orientation !== '' && Orientation !== 1) {
// 创建临时canvas 用来调整正确方位
canvas = document.createElement('canvas');
switch (Orientation) {
case 6://需要顺时针(向左)90度旋转
console.log(image.width, image.height);
rotateImg(image, 'left', canvas);
break;
case 8://需要逆时针(向右)90度旋转
rotateImg(image, 'right', canvas);
break;
case 3://需要180度旋转
rotateImg(image, 'right', canvas);//转两次
rotateImg(image, 'right', canvas);
break;
}
}
else {
canvas = compress(image);
}
// 对缩小比例后的canvas再进行压缩
let compressData = canvas.toDataURL("image/jpeg", 0.3); // 默认MIME image/png
let blob = convertBase64UrlToBlob(compressData);
// 提交数据
submitFormData(blob);
}
}
}
}
二、 解决了图片旋转和图片格式问题在压缩前需要解决canvas绘制图片的两个限制和图片格式转换的问题。
-
两个限制
- 问题
第一是图片的大小:如果图片的大小超过两百万像素,图片也是无法绘制到canvas上的,调用drawImage的时候不会报错,但是你用toDataURL获取图片数据的时候获取到的是空的图片数据。
第二是canvas的大小有限制,如果canvas的大小大于大概五百万像素(宽 * 高)时,不仅图片画不出来,其他什么东西也都是画不出来的。
- 解决方法
第一种限制,处理办法就是瓦片绘制。瓦片绘制,也就是将图片分割成多块绘制到canvas上,代码里的实现是把图片分割成100万像素一块的大小,再绘制到canvas上。
第二种限制,对图片的宽高进行适当压缩。具体实现以上限四百万像素为基准,如果图片大于四百万像素就压缩到小于四百万像素。
如果是png转jpg,绘制到canvas上的时候,canvas存在透明区域的话,当转成jpg的时候透明区域会变成黑色,因为canvas的透明像素默认为rgba(0,0,0,0),所以转成jpg就变成rgba(0, 0, 0 ,1)了,也就是透明背景会变成了黑色。解决办法就是绘制之前在canvas上铺一层白色的底色。
function compress(image) {
let {width, height} = image;
// 创建canvas 获取上下文
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
/**
* 判断像素大小
* 像素 = 宽 * 高
*/
// 如果像素大于400万 则需计算压缩比 压缩至400万以下
let ratio = (width * height) / 4000000;
if (ratio > 1) {
// 倍数开方 (相当于面积为多少倍,则宽高对应的倍数需对面积倍数开方)
ratio = Math.sqrt(ratio);
// 宽高对应的值
width /= ratio;
height /= ratio;
}
else {
ratio = 1;
}
// 画布宽高
canvas.width = width;
canvas.height = height;
// 铺底色
ctx.fillStyle = '#fff';
// 绘制矩形
ctx.fillRect(0, 0, width, height);
// 如果缩放比例后画布像素仍大于100万像素 则使用瓦片绘制, 反之直接绘制
let count = width * height / 1000000;
if (count > 1) {
// 创建瓦片 获取2d上下文
let tcanvas = document.createElement('canvas');
let tctx = tcanvas.getContext('2d');
/**
* 瓦片数量 = count的平方 + 1
* +1不是必须得,是为了瓦片更小,数量更多一些
*/
count = ~~(Math.sqrt(count) + 1); // 比如count为2.3 则转成3
let tWidth = ~~(width / count);
let tHeight = ~~(height / count);
// 瓦片的宽高
tcanvas.width = tWidth;
tcanvas.height = tHeight;
for (let i = 0; i < count; i++) {
for (let j = 0; j < count; j++) {
tctx.drawImage(image, i * tWidth * ratio, j * tHeight * ratio, tWidth * ratio, tHeight * ratio, 0, 0, tWidth, tHeight);
console.log(tcanvas.width, tcanvas.height, tcanvas.width * tcanvas.height);
ctx.drawImage(tcanvas, i * tWidth, j * tHeight, tWidth, tHeight);
}
}
}
else {
// 直接绘制
ctx.drawImage(image, 0, 0, width, height);
}
return canvas;
}
- 完成图片压缩后,先将base64提取出来,再实例化一个ArrayBuffer,然后将字符串以8位整型的格式传入ArrayBuffer,再通过Blob对象(可能需要兼容Blob),将8位整型的ArrayBuffer转成二进制对象blob,然后把blob对象append到formdata里,再提交给后台。
function convertBase64UrlToBlob(urlData) {
let bytes = window.atob(urlData.split(',')[1]);
// 处理异常,将ascii码小于0的转换为大于0
let ab = new ArrayBuffer(bytes.length);
let ia = new Uint8Array(ab);
for (let i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
// 二进制对象
return getBlob([ab], "image/jpeg");
}
- 兼容Blob对象
/**
* Blob对象的兼容性写法
* @param buffer 数据流
* @param format 表示将会被放入到blob中的数组内容的MIME类型。类型默认 ''
*/
function getBlob(buffer, format = 'image/jpeg') {
try {
return new Blob(buffer, {
type: format
});
}
catch (e) {
let blob = new (window.BlobBuilder || window.WebKitBlobBuilder || window.MSBlobBuilder)();
buffer.forEach(function (buf) {
blob.append(buf);
});
return blob.getBlob(format);
}
}
- 低版本的Android机不支持FormData,需要做兼容处理。首先判断是否需要兼容
function needsFormDataShim() {
return ~navigator.userAgent.indexOf('Android')
&& ~navigator.vendor.indexOf('Google')
&& !~navigator.userAgent.indexOf('Chrome')
&& navigator.userAgent.match(/AppleWebKit\/(\d+)/).pop() <= 534;
}
- 给不支持FormData上传Blob的android机打补丁,定义boundary分隔符,设置请求体。重写XMLHttpRequest原型的send方法。
function FormDataShim() {
let o = this,
// 请求体
parts = [],
// 分隔符
boundary = Array(5).join('-') + (+new Date() * (1e16 * Math.random())).toString(36),
oldSend = XMLHttpRequest.prototype.send;
this.append = function (name, value, filename) {
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"`);
if (value instanceof Blob) {
parts.push(`; filename="${filename || 'blob'}"\r\nContent-Type: ${value.type}\r\n\r\n`);
parts.push(value);
}
else {
parts.push('\r\n\r\n' + value);
}
parts.push('\r\n');
};
// override XHR send()
XMLHttpRequest.prototype.send = function (val) {
let fr,
data,
oXHR = this;
if (val === o) {
// 不能漏最后的\r\n ,否则服务器有可能解析不到参数.
parts.push(`--${boundary}--\r\n`);
// 创建Blob对象
data = getBlob(parts);
// Set up and read the blob into an array to be sent
fr = new FileReader();
fr.onload = function () {
oldSend.call(oXHR, fr.result);
};
fr.onerror = function (err) {
throw err;
};
fr.readAsArrayBuffer(data);
// 设置请求头Content-Type的类型和分隔符 服务端是根据Content-Type来解析请求体中
this.setRequestHeader(
'Content-Type',
`multipart/form-data; boundary=${boundary}`
);
XMLHttpRequest.prototype.send = oldSend;
}
else {
oldSend.call(this, val);
}
};
}
- 提交数据。判断是否支持FormData
function submitFormData(blob) {
let isNeedShim = needsFormDataShim();
let formdata = isNeedShim ? new FormDataShim() : new FormData();
formdata.append('imagefile', blob);
if (isNeedShim) {
let ajax = new XMLHttpRequest();
ajax.open('POST', '/');
ajax.onreadystatechange = function() {
if (ajax.status === 200 && ajax.readyState === 4) {
}
}
ajax.send(formdata);
}
else {
// 调用API
axios.post('/upload', formdata)
.then(response => {
console.log(response);
})
.catch(error => {
console.log(error);
});
// axios 会根据提交的文件类型,设置相应的Content-Type类型
}
}
- 旋转图片
/**
* @param 旋转的图片
* @param 方向
* @param 绘制的canvas
*/
function rotateImg(img, direction, canvas) {
//最小与最大旋转方向,图片旋转4次后回到原方向
const min_step = 0;
const max_step = 3;
if (img == null) return;
// 缩小比例后的canvas
let lessCnavas = compress(img);
let {width, height} = lessCnavas;
let step = 2;
if (step == null) {
step = min_step;
}
if (direction == 'right') {
step++;
//旋转到原位置,即超过最大值
step > max_step && (step = min_step);
} else {
step--;
step < min_step && (step = max_step);
}
//旋转角度以弧度值为参数
let degree = (step * 90 * Math.PI) / 180;
let ctx = canvas.getContext('2d');
switch (step) {
case 0:
canvas.width = width;
canvas.height = height;
ctx.drawImage(lessCnavas, 0, 0);
break;
case 1:
canvas.width = height;
canvas.height = width;
ctx.rotate(degree);
ctx.drawImage(lessCnavas, 0, -height);
break;
case 2:
canvas.width = width;
canvas.height = height;
ctx.rotate(degree);
ctx.drawImage(lessCnavas, -width, -height);
break;
case 3:
canvas.width = height;
canvas.height = width;
ctx.rotate(degree);
ctx.drawImage(lessCnavas, -width, 0);
break;
}
}
以上就是压缩上传的全部实现。