参考1-HTML5实现图片压缩上传功能
参考2-移动前端—图片压缩上传实践
参考3-移动端H5图片压缩上传
大体步骤
- 用户使用input file上传图片的时候,利用 FileReader ,读取 blob对象 ,或者是 file对象 ,将图片转化为 data uri (base64格式)的形式。
- 使用 canvas ,在页面上新建一个画布,利用 canvas 提供的API,将图片画入这个画布当中。
- 利用 canvas.toDataURL() ,进行图片的压缩,得到图片的 data uri 的值,用来上传。
- 获取到压缩后的base64格式图片数据,转成二进制塞入formdata,再通过XmlHttpRequest提交formdata。
页面结构
上传图片
只允许上传jpg、png及gif
预览上传的图片
- 先是获取图片数据,也就是监听input file的change事件,然后获取到上传的文件对象files,将类数组的files转成数组,然后进行forEach遍历。
- 接着判断文件类型,如果不是图片则不作处理。
- 如果是图片就实例化一个filereader,利用FileReader读取file对象,将图片格式转化为data URI的形式。
- 在进行图片压缩前,还是对图片大小做了判断的,如果图片大小(数据长度)大于200KB时,是直接进行图片上传并预览,不进行图片的压缩;如果图片的大小是大于200KB,则是先进行图片的compress方法压缩、预览再上传。
// 获取选择文件表单
var filechooser = document.getElementById("choose");
// 点击上传
$("#upload").on("click", function() {
filechooser.click();
})
.on("touchstart", function() {
$(this).addClass("touch")
})
.on("touchend", function() {
$(this).removeClass("touch")
});
filechooser.onchange = function() {
if (!this.files.length) return;
var files = Array.prototype.slice.call(this.files);
if (files.length > 9) {
alert("最多同时只可上传9张图片");
return;
}
files.forEach(function(file, i) {
if (!/\/(?:jpeg|png|gif)/i.test(file.type)) return;
var reader = new FileReader();
var li = document.createElement("li");
// 获取图片大小
var size = file.size / 1024 > 1024 ? (~~(10 * file.size / 1024 / 1024)) / 10 + "MB" : ~~(file.size / 1024) + "KB";
li.innerHTML = '' + size + '';
$(".img-list").append($(li));
reader.onload = function() {
var result = this.result;
var img = new Image();
img.src = result;
// 如果图片大小小于200kb,则直接上传
if (result.length <= 200 * 1024) {
img = null;
$(li).children('div.img').append('![]('+result+')'); // 预览
upload(result, file.type, $(li)); // 直接上传
return;
}
// 图片加载完毕之后进行压缩,然后上传
if (img.complete) {
callback();
} else {
img.onload = callback;
}
function callback() {
var data = compress(img); // 使用canvas对大图片进行压缩
$(li).children('div.img').append('![]('+data+')'); //预览
upload(data, file.type, $(li)); // 上传
img = null;
}
};
reader.readAsDataURL(file);
})
};
图片压缩使用canvas
上面做完图片数据的获取后,就可以做compress压缩图片的方法了。而 压缩图片也并不是直接把图片绘制到canvas再调用一下 toDataURL就行的。
在IOS中,canvas绘制图片是有两个限制的:
- 首先是图片的大小,如果图片的大小超过两百万像素,图片也是无法绘制到canvas上的,调用drawImage的时候不会报错,但是你用toDataURL获取图片数据的时候获取到的是空的图片数据。
- 再者就是canvas的大小有限制,如果canvas的大小大于大概五百万像素(即宽高乘积)的时候,不仅图片画不出来,其他什么东西也都是画不出来的。
应对第一种限制,处理办法就是瓦片绘制了。瓦片绘制,也就是将图片分割成多块绘制到canvas上,我代码里的做法是把图片分割成100万像素一块的大小,再绘制到canvas上。
而应对第二种限制,我的处理办法是对图片的宽高进行适当压缩,我代码里为了保险起见,设的上限是四百万像素,如果图片大于四百万像素就压缩到小于四百万像素。四百万像素的图片应该够了,算起来宽高都有2000X2000了。
如此一来就解决了IOS上的两种限制了。
除了上面所述的限制,还有两个坑。
- canvas的toDataURL是只能压缩jpg的,当用户上传的图片是png的话,就需要转成 jpg,也就是统一用canvas.toDataURL('image/jpeg', 0.1) , 类型统一设成jpeg,而压缩比就自己控制了。
- 如果是png转jpg,绘制到canvas上的时候,canvas存在透明区域的话,当转成jpg的时候透明区域会变成黑色,因为 canvas的透明像素默认为rgba(0,0,0,0),所以转成jpg就变成rgba(0,0,0,1)了,也就是透明背景会变成了黑色。解决办法就 是绘制之前在canvas上铺一层白色的底色。
在利用canvas进行绘图的过程中,IOS图片上传过程中,存在着这样的问题:
- 当你竖着拿手机的时候,拍完照,上传图片时,会出现照片自动旋转的情况,而横着拍照并上传图片时不会出现这个问题。这个时候如果想纠正图片自动旋转的情况,将图片转化为二进制的数据 (使用了binaryajax.js) ,方便获取图片的 exif信息 ,通过获取 exif的信息 来确定图片旋转的角度 (使用了exif.js) ,然后再进行图片相应的旋转处理。
- 在 IOS 中,当图片的大小大于 2MB时,会出现图片压扁的情况,这个时候需要重置图片的比例。
- 利用FileReader,读取图片的过程需要花费一定时间,将图片数据注入到canvas画布中需要一定时间,图片压缩的过程中,图片越大,CPU计算消耗的时间也越长,可能会出现顿卡的情况。总之,就是这个过程当中需要花费一定时间。
- IOS8.1的版本中有个 FileReader 的bug: FileReader 读取的图片转化为Base64时,字符串为空,遇到这个情况的话- - 还是老老实实把图片不做压缩处理扔给服务端吧。
// 使用canvas对大图片进行压缩
function compress(img) {
// 用于压缩图片的canvas
var canvas = document.createElement("canvas");
var ctx = canvas.getContext('2d');
// 瓦片canvas
var tCanvas = document.createElement("canvas");
var tctx = tCanvas.getContext("2d");
var initSize = img.src.length;
var width = img.width;
var height = img.height;
//如果图片大于四百万像素,计算压缩比并将大小压至400万以下
var ratio;
if ((ratio = width * height / 4000000) > 1) {
ratio = Math.sqrt(ratio); // 平方根
width /= ratio;
height /= ratio;
} else {
ratio = 1;
}
canvas.width = width;
canvas.height = height;
// 铺底色(防止png透明背景转成JPG变黑色)
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 如果图片像素大于100万则使用瓦片绘制
var count;
if ((count = width * height / 1000000) > 1) {
count = ~~(Math.sqrt(count) + 1); // 计算要分成多少块瓦片
// 计算每块瓦片的宽和高
var nw = ~~(width / count); // ~~取整
var nh = ~~(height / count);
tCanvas.width = nw;
tCanvas.height = nh;
for (var i = 0; i < count; i++) {
for (var j = 0; j < count; j++) {
// drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight,destX, destY, destWidth, destHeight)
tctx.drawImage(img, i * nw * ratio, j * nh * ratio, nw * ratio, nh * ratio, 0, 0, nw, nh);
ctx.drawImage(tCanvas, i * nw, j * nh, nw, nh);
}
}
} else {
ctx.drawImage(img, 0, 0, width, height);
}
//进行最小压缩(将原来图片的质量压缩到原先的0.1倍)
var ndata = canvas.toDataURL('image/jpeg', 0.1);
console.log('压缩前:' + initSize);
console.log('压缩后:' + ndata.length);
console.log('压缩率:' + ~~(100 * (initSize - ndata.length) / initSize) + "%");
tCanvas.width = tCanvas.height = canvas.width = canvas.height = 0;
console.log(ndata);
return ndata;
}
图片上传
文件上传有2种方式:
- 将图片转化为 base64,直接传给后台。
- 先将base64数据转成字符串,再实例化一个ArrayBuffer,然后将字符串 以8位整型的格式传入ArrayBuffer,再通过BlobBuilder或者Blob对象,将8位整型的ArrayBuffer转成二进制对象 blob,然后把blob对象append到formdata里,再通过ajax发送给后台即可。
// 图片上传,将base64的图片转成二进制对象,塞进formdata上传
function upload(basestr, $li) {
// 将以base64的图片url数据转换为Blob
var blob = convertBase64UrlToBlob(basestr);
var pecent = 0, loop = null;
var xhr = new XMLHttpRequest();
var formdata = getFormData();
formdata.append('imagefile', blob);
xhr.open('post', '/cupload');
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200) {
var jsonData = JSON.parse(xhr.responseText);
var imagedata = jsonData[0] || {};
var text = imagedata.path ? '上传成功' : '上传失败';
console.log(text + ':' + imagedata.path);
clearInterval(loop);
//当收到该消息时上传完毕
$li.find(".progress span").animate({'width': "100%"}, pecent < 95 ? 200 : 0, function() {
$(this).html(text);
});
if (!imagedata.path) return;
$(".pic-list").append('' + imagedata.name + '(' + imagedata.size + ')![](' + imagedata.path + ')');
}
};
//数据发送进度,前50%展示该进度
xhr.upload.addEventListener('progress', function(e) {
if (loop) return;
pecent = ~~(100 * e.loaded / e.total) / 2;
$li.find(".progress span").css('width', pecent + "%");
if (pecent == 50) {
mockProgress();
}
}, false);
//数据后50%用模拟进度
function mockProgress() {
if (loop) return;
loop = setInterval(function() {
pecent++;
$li.find(".progress span").css('width', pecent + "%");
if (pecent == 99) {
clearInterval(loop);
}
}, 100)
}
xhr.send(formdata);
}
另外一种方式并不是直接在选择图片这里上传,而是页面还有很多不止图片的数据需要一次性提交的。
// 第一种
var formdata = getFormData(form); // 获取form表单中所有数据,相当于序列化表单数据
formdata.append('imagefile', blob); // 追加
$.ajax({
url: 'http://example.com/api',
method: 'post',
processData: false
contentType: false,
data: formdata
});
// 第二种 ,form表单直接提交,没有success回调的
var imgSrc = $("img").attr("src"); // 获取到预览img的src属性
// base64格式转换成blob添加到表单中提交给后台
var imageFile = '
将以base64的图片url数据转换为Blob
/**
* 将以base64的图片url数据转换为Blob
* @param urlData
* 用url方式表示的base64图片数据
*/
function convertBase64UrlToBlob(urlData){
var bytes=window.atob(urlData.split(',')[1]); //去掉url的头,并转换为byte
var type = urlData.split('/')[1].split(';')[0];
console.log(type);
//处理异常,将ascii码小于0的转换为大于0
var ab = new ArrayBuffer(bytes.length);
var ia = new Uint8Array(ab);
for (var i = 0; i < bytes.length; i++) {
ia[i] = bytes.charCodeAt(i);
}
return getBlob( [ab] , type);
}
/**
* 获取blob对象的兼容性写法
* @param buffer
* @param format
* @returns {*}
*/
function getBlob(buffer, format) {
try {
return new Blob(buffer, {type: format});
} catch (e) {
var bb = new (window.BlobBuilder || window.WebKitBlobBuilder || window.MSBlobBuilder);
buffer.forEach(function(buf) {
bb.append(buf);
});
return bb.getBlob(format);
}
}
/**
* 获取formdata
*/
function getFormData() {
var isNeedShim = ~navigator.userAgent.indexOf('Android')
&& ~navigator.vendor.indexOf('Google')
&& !~navigator.userAgent.indexOf('Chrome')
&& navigator.userAgent.match(/AppleWebKit\/(\d+)/).pop() <= 534;
return isNeedShim ? new FormDataShim() : new FormData()
}
/**
* formdata 补丁, 给不支持formdata上传blob的android机打补丁
* @constructor
*/
function FormDataShim() {
console.warn('using formdata shim');
var o = this,
parts = [],
boundary = Array(21).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) {
var fr,
data,
oXHR = this;
if (val === o) {
// Append the final boundary string
parts.push('--' + boundary + '--\r\n');
// Create the 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);
// Set the multipart content type and boudary
this.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + boundary);
XMLHttpRequest.prototype.send = oldSend;
}
else {
oldSend.call(this, val);
}
};
}
使用 jQuery,上传formdata的小问题 参考- 关于 HTML5 的文件上传处理,兼容,以及 BLOB 对象的使用
如果使用 jQuery,产生含有数据的 FormData
对象之后,我们可以将其传进$.ajax
的 data
参数里面。这种情况,只需要将 formdata
对象传入,并且制定 processData
和 contentType
为 false
,就可以用 multipart/form-data
的方式通过 ajax post
一个请求出去,当然这里面就可以包含一般的二进制对象(File 或者 Blob),但是实际测试中发现腾讯QQ浏览器在将 Blob 传入 Formdata 中的时候就会出问题,肯定是内核对 FormData 的实现上面有 Bug。
于是为了兼容这个问题,封装一个模拟出来的表单,即由 boundary
分割的multipart/form-data
请求体。
请求体的封装
如果使用 multipart/form-data 的 Content-Type 去提交一个请求,实际上发出的 HTTP 请求是这样的:
// 请求头
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryWwE7y8P3JK82rxsk
// 请求体
------WebKitFormBoundaryWwE7y8P3JK82rxsk
Content-Disposition: form-data; name="username"
admin
------WebKitFormBoundaryWwE7y8P3JK82rxsk
Content-Disposition: form-data; name="avatar"; filename="avatar.png"
Content-Type: image/png
[binary stream]
------WebKitFormBoundaryWwE7y8P3JK82rxsk--
关键的格式就是这样,只要满足这个规范,后台就可以从$_POST
和 $_FILE
获取提交的字段或者上传的文件。
// 其中,请求头很简单,首先随机一个 boundary 字符串,然后通过 ajax 的 contentType 参数输入即可:
var makeBoundary = function() {
return '----JQBoundary'+btoa(Math.random().toString()).substr(0,12);
};
var boundary = makeBoundary();
$.ajax({
contentType: 'multipart/form-data; boundary='+boundary,
// ...
});
// 不涉及二进制的传递
$.ajax(url, {
method: 'post',
processData: false,
contentType: 'multipart/form-data; boundary='+boundary,
data: '--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="username"\r\n\r\n' +
'呆滞的慢板\r\n' +
'--' + boundary + '--\r\n'
});
由于文本类型(而且还是 unicode 文本)类型与直接的二进制流放在一起,产生了编码混乱,ajax 发出之间,由于这是一个字符串,因此 xhr 对象帮我们自动编码这个字符串,结果造成了二进制流的破坏,后台识别不出来了。可以通过unicode
和二进制混编构造的字符串,在传递给 ajax 之前,将其一个一个字节编码到 Uint8Array
中,再获取其 buffer
,作为 data
传给 ajax。
/**
* unicode 字符一个一个拆开
* Encode a given string to utf8 encoded binary string.
* @param str:
* @returns string:
*/
var str2utf8 = window.TextEncoder ? function(str) {
var encoder = new TextEncoder('utf8');
var bytes = encoder.encode(str);
var result = '';
for(var i = 0; i < bytes.length; ++i) {
result += String.fromCharCode(bytes[i]);
}
return result;
} : function(str) {
return eval('\''+encodeURI(str).replace(/%/gm, '\\x')+'\'');
};
/**
* 其编码成 Uint8Array
*/
var str2Uint8Array = function(str) {
var arr = [], c;
for(var i = 0; i < str.length; ++i) {
c = str.charCodeAt(i);
if(c > 0xff) {
alert('Char code range out of 8 bit, parse error!');
return [];
}
arr.push(str.charCodeAt(i));
}
return new Uint8Array(arr);
};
// 那么最终我们可以这样来发送一个 ajax,就可以完全兼容二进制流和普通字段了:
var strctured_body = '...'; // 这是我们手工混编出来的,带有 unicode 字符的完整 request body
var encoded_body = str2utf8(structured_body);
var byte_array = str2Uint8Array(encoded_body);
$.ajax(url, {
method: 'post',
processData: false,
contentType: 'multipart/form-data; boundary='+boundary,
data: byte_array.buffer
});
关于直接上传base64格式的限制。
get 和post的长度限制 参考
- HTTP协议规范没有对URL长度进行限制。这个限制是特定的浏览器及服务器对它的限制。IE对URL长度的限制是2083字节(2K+35)。对于其他浏览器,如Netscape、FireFox等,理论上没有长度限制,其限制取决于操作系统的支持。
- 理论上讲,POST是没有大小限制的。HTTP协议规范也没有进行大小限制,起限制作用的是服务器的处理程序的处理能力。
在线演示