前言

由于前端是不能直接操作本地文件的, 要么通过 <input type= "file"> 用户单击选择文件或者拖拽的方式,要么使用 Flash 等三方的控件。同时 HTMLS 崛起,可以在前端使用原生的 API 实现图片的处理,这样可以减少后端服务器的压力,同时对用户也是友好的。

这里面有几个核心的功能:

  1. 支持拖拽
  2. 压缩
  3. 裁剪编辑
  4. 上传和上传进度显示

实践

接下来会依次介绍每个功能的实现!

1. 拖拽显示图片

拖拽读取的功能主要是要兼听 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, 则监听 inputchange 事件,

// 如果使用的是 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。

2. 压缩图片

压缩图片可以借助 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 图片旋转的问题,得到一张旋转和压缩调节过的图片之后,再用它进行裁剪和编辑。

3. 裁剪图片

裁剪图片,使用了一个 cropper 插件,这个插件还是挺强大的, 支待裁剪、旋转、翻转,但是它并没有对图片真正的处理,只是记录了用户做了哪些变换,然后你自己再去处理。

3.1 简单裁剪

假设用户没有进行旋转和翻转,只是简单地选了区域裁剪了一下,那就简单很多。

最简单的办法就是创建一个 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);

3.2 裁剪加翻转

如果用户做了翻转, 只需要在 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);

其他的都不用变,就可以实现上下左右翻转了,难点在于既要翻转又要旋转

3.3 旋转加翻转剪裁

两种变化叠加没办法直接通过变化 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();
}

4. 文件上传和上传进度

文件上传只能通过表单提交的形式,编码方式为 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 才兼容,问题应该不大。这个东西一来减少了后端的压力, 二来不用和后端来回交互, 对用户体验来说还是比较好的, 除了上面说的有一个地方会被卡一下之外。