由于前端是不能直接操作本地文件的, 要么通过 <input type= "file">
用户单击选择文件或者拖拽的方式,要么使用 Flash 等三方的控件。同时 HTMLS 崛起,可以在前端使用原生的 API 实现图片的处理,这样可以减少后端服务器的压力,同时对用户也是友好的。
这里面有几个核心的功能:
- 支持拖拽
- 压缩
- 裁剪编辑
- 上传和上传进度显示
接下来会依次介绍每个功能的实现!
拖拽读取的功能主要是要兼听 HTML5 的 drag
事件, 这个没什么好说的,主要在于怎么读取用户拖过来的图片并把它转成 base64 以在本地显示。
监听 drop 事件
var handler = {
init: function($container) {
// 需要把dragover的默认行为禁掉,不然会跳页
$container.on('dragover', function(event) {
event.preventDefault();
});
$container.on('drop', function(event) {
event.preventDefault();
// 这里获取拖过来的图片文件, 为一个File对象
var file = event.originalEvent.dataTransfer.files[O];
handler.handleDrop($(this), file);
});
}
};
这里获取到图片文件之后交给 handleDrop
来处理。
注意如果使用 input
, 则监听 input
的 change
事件,
// 如果使用的是 input,监听change事件
$container.on('change', 'input[type=file]', function(event) {
if (!this.value) return;
var file = this.files[O];
handler.handleDrop($(this).closest('.container'), file);
this.value = '';
});
在 handleDrop
函数里, 读取 file 的内容, 并把它转成 base64 的格式:
// 读取File内容并转base64
handleDrop: function($container, file) {
var $img = $container.find("img");
handler.readimgFile(file, $img, $container);
}
readimgFile
读取图片文件内容
// 读取图片文件内容
readimgFile: function(file, $img, $container) {
var reader = new FileReader(file);
// 根据mime type检验用户是否选则是图片文件
if (file.type.split("/")[0] !== "image") {
// util.toast("You should choose an image file"); // 工具弹出插件,如果没有请自行封装
alert("You should choose an image file")
return;
}
reader.onload = function(event) {
// 获取图片base64内容
var base64 = event.target.result;
// 如果图片大千1MB, 将body置半透明
/*
* 如果图片有几个 MB 的,展示的时候会被卡一下, 通过把页面变虚的方式告诉用户现在正在处理之中,页面不可操作,请稍等一会。
*/
if (file.size > ONE_MB) {
$("body").css("opacity", 0.5);
}
// 因为这里图片太大会被卡一下, 整个页面会不可操作
$img.attr("src", baseUrl);
// 还原
if (file.size > ONE_MB) {
$("body").css("opacity", 1);
}
// 然后再调一个压缩和上传的函数
handler.compressAndUpload($img, base64, file, $container);
}
// 读取为base64格式
reader.readAsDataURL(file);
}
通过 FileReader
读取文件内容, 调的是 readAsDataURL
, 这个 API 能够把二进制图片内容转成 base64 的格式, 读取完之后会触发 onload
事件, 在 onload
里面进行显示和上传。
还会有一个问题, 就是 ios 系统拍摄的照片,如果不是横着拍的,展示出来的照片旋转角度会有问题,即不管你怎么拍,ios 实际存的图片都是横着放的,因此需要用户自己手动去旋转。 旋转的角度放在了 exif 的数据结构里面,把这个读出来就知道它的旋转角度了, 用一个 EXIF 的库读取
readImgFile: function(file, $img, $container) {
EXIF.getData(file, function() {
var orientation = this.exifdata.Orientation,
rotateDeg = O;
// 如果不是ios拍的照片或者是横拍的,则不用处理,直接读取
if (typeof orientation === "undefined" || orientation === 1) {
// 原本的readImgFile, 添加一个rotateDeg的参数
handler.doReadImgFile(file, $img, $container, rotateDeg);
}
// 否刻用canvas旋转一下
else {
rotateDeg = orientation === 6 ? 90 * Math.PI / 180 :
orientation === 8 ? -90 * Math.PI / 180 :
orientation === 3 ? 180 * Math.PI / 180 : 0;
handler.doReadImgFile(file, $img, $container, rotateDeg);
}
});
}
知道角度之后, 就可以用 canvas 处理了, 在下面的压缩图片再进行说明, 因为压缩也要用到 canvas。
压缩图片可以借助 canvas, canvas 可以很方便地实现压缩,其原理是把一张图片画到一个小的画布,然后再把这个画布的内容导出 base64, 就能够拿到一张被压小的图片了。
在 compress
函数里面进行压缩
// 设定圉片最大压缩宽度为1500px
var maxWidth = 1500;
var resultimg = handler.compress($img[O], maxWidth, file.type); // 调用压缩
在 compress 这个函数里首先创建一个 canvas 对象,然后计算这个画布的大小,
compress: function(img, maxWidth, mimeType) {
// 创建一个canvas对象
var cvs = document.createElement('canvas');
var width = img.naturalWidth,
height = img.naturalHeight,
imgRatio = width / height;
// 如果历片维度超过了给定的maxWidth 1500,
// 为了保持圉片宽高比, 计算西布的大小
if (width > maxWidth) {
width = maxWidth;
height = width / imgRatio;
}
cvs.width = width;
cvs.height = height;
}
接下来把大的图片画到一个小的画布上
var ctx = cvs.getContext('2d');
ctx.drawImage(
img,
O,
O,
img.naturalWidth,
img.naturalHeight,
0,
0,
width,
height
);
// 图片质量进行适当压缩
var quality = width >= 1500 ? 0.5 : width > 600 ? 0.6 : 1;
// 导出图片为base64
var newImageData = cvs.toDataURL(mimeType, quality);
var resultImg = new Image();
resultImg.src = newImageData;
return resultImg;
最后一行返回了一个被压缩过的小图片。这里有个问题需要注意一下,有的浏览器在把 base64 赋值给 new 出来的 Image 的 src 时,是异步的操作,特别是 Safari, 所以要用监听 onload, 才能对此图片进行下一步的处理
resultImg.onload = function() {
ctx.drawImage(
img,
O,
O,
img.naturalWidth,
img.naturalHeight,
0,
0,
width,
height
);
};
resultImg.src = newImageData;
由千前面提到 ios 拍的照片需要旋转一下, 在压缩的时候可以一起处理。也就是说,如果需要旋转的话,那么画在 canvas 上面的时候就把它旋转好了
var ctx = cvs.getContext("2d");
var destX = 0,
destY = O;
if (rotateDeg) {
ctx.translate(cvs.width / 2, cvs.height / 2);
ctx.rotate(rotateDeg);
destX = -width / 2;
destY = -height / 2;
}
...
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, destX, destY, width, height);
需要先把 canvas 的原点移到画布的中心,然后再进行旋转,默认原点是在左上角,原理和transform
类似。
这样就解决了 ios 图片旋转的问题,得到一张旋转和压缩调节过的图片之后,再用它进行裁剪和编辑。
裁剪图片,使用了一个 cropper 插件,这个插件还是挺强大的, 支待裁剪、旋转、翻转,但是它并没有对图片真正的处理,只是记录了用户做了哪些变换,然后你自己再去处理。
假设用户没有进行旋转和翻转,只是简单地选了区域裁剪了一下,那就简单很多。
最简单的办法就是创建一个 canvas, 它的大小就是选框的大小, 然后根据起点 x、y 和宽高把图片相应的位置画到这个画布,再导出图片就可以了。
由于考虑到需要翻转, 所以用第二种方法:创建一个和图片一样大小的 canvas, 把图片原封不动地画上去, 然后把选中区域的数据 imageData
存起来, 重新设置画布的大小为选中框的大小, 再把 imageData
画上去,最后再导出就可以了,
var cvs = document.createElement('canvas');
var img = $img[O];
var width = img.naturalWidth,
height = img.naturalHeight,
cvs.width = width;
cvs.height = height;
var ctx = cvs.getContext("2d");
var destX = 0,
destY = O;
ctx.drawImage(img, destX, destY);
// 把选中框里的图片内容存起来
var imageData = ctx.getImageData(cropOptions.x, cropOptions.y, cropOptions.width, cropoptions.height);
cvs.width = cropOptions.width;
cvs.height = cropOptions.height;
// 然后再画上去
ctx.putImageData(imageData, 0, 0);
如果用户做了翻转, 只需要在 drawlmage
之前对画布做一下翻转变化
if (cropOptions.scaleX === -1 || cropOptions.scaleY === -1) {
// 水平翻转
destX = cropOptions.scaleX === -1 ? width * -1 : 0;
// 垂直翻转
destY = cropOptions.scaleY === -1 ? height * -1 : 0;
ctx.scale(cropOptions.scaleX, cropOptions.scaleY);
}
ctx.drawlmage(img, destX, destY);
其他的都不用变,就可以实现上下左右翻转了,难点在于既要翻转又要旋转
两种变化叠加没办法直接通过变化 canvas 的坐标一次性 drawImage
上去,有两种办法,第一种是用 imageData
进行数学变换,计算一遍得到 imageData
里面从第一行到最后一行每个像素新的 rgba 值是多少,然后再画上去。第二种是创建第二个 canvas,第一个 canvas 做翻转时把它画到第二个 canvas,第二个再进行旋转,我们用第二种办法
在第一个 canvas 画完之后,创建第二个 canvas 进行旋转。
ctx.drawlmage(img, destX, destY);
//rotate
if (cropOptions.rotate !== O) {
var newCanvas = document.createElement('canvas'),
deg = (cropOptions.rotate / 180) * Math.PI;
// 旋转之后, 导致画布变大, 需要计算一下
newCanvas.width =
Math.abs(width * Math.cos(deg)) + Math.abs(height * Math.sin(deg));
newCanvas.height =
Math.abs(width * Math.sin(deg)) + Math.abs(height * Math.cos(deg));
var newContext = newCanvas.getContext('2d');
newContext.save();
newContext.translate(newCanvas.width / 2, newCanvas.height / 2);
newContext.rotate(deg);
(destX = -width / 2), (destY = -height / 2);
// 将第一个canvas的内容在经旋转后的坐标系画上来
newContext.drawImage(cvs, destX, destY);
newContext.restore();
}
文件上传只能通过表单提交的形式,编码方式为 multipart/form-data
, 可以通过写个 form 标签进行提交, 也可以用 AJAX 模拟表单提交的格式。
首先创建一个 AJAX 请求,并设置编码方式
var xhr = new XMLHttpRequest();
xhr.open('POST', upload_url, true);
var boundary = 'someboundary';
xhr.setRequestHeader(
'Content-Type',
'multipart/form-data; boundary=' + boundary
);
然后拼表单格式的数据进行上传
// 拼表单提交的数据形式
var data = img.src;
data = data.replace('data:' + file.type + ';base64,', '');
xhr.sendAsBinary(
[
//name=data
'--' + boundary,
'Content-Disposition: form-data; name="data"; filename="' + file.name + '"',
'Content-Type: ' + file.type,
'',
atob(data),
'--' + boundary,
// name=docName
'--' + boundary,
'Content-Disposition: form-data; name="docName"',
'',
file.name,
'--' + boundary + '--'
].join('\r\n')
);
atob
将 base64 解码为二进制的格式, 符合表单提交的数据格式要求。
表单数据不同的字段是用 boundary
的随机字符串分隔的, 拼好之后用 sendAsBinary
发出去。 这个上传功能参考了一个 JIC 插件, 但是由于这个 API 已经废弃了, 所以新代码不推荐再使用这种方式。
在调这个函数之前先监听下它的事件
1. 上传的进度
xhr.upload.onprogress = function(event) {
if (event.lengthComputable) {
duringCallback((event.loaded / event.total) * 100);
}
};
这里调用 duringCallback
的回调函数, 给这个回调函数传了当前进度的参数, 用这个参数就可以设置进度条的过程了。 进度条可以自已实现, 或者直接上网找一个。
2. 成功和失败
需要对成功和失败做一些反馈处理,
xhr.onreadystatechange = function() {
if (this.readyState === 4) {
if (this.status === 200) {
successCallback(this.responseText);
} else if (this.status >= 400) {
if (errorCallback) {
errorCallback(this.responseText);
}
}
}
};
至此整个功能就拆解说明完了, 上面的代码可以兼容到 IE10, FileReader
的 API 到 IElO 才兼容,问题应该不大。这个东西一来减少了后端的压力, 二来不用和后端来回交互, 对用户体验来说还是比较好的, 除了上面说的有一个地方会被卡一下之外。