HTML5上传图片

基础

HTML5上传一般是异步,会使用到FormData构建需要递交的文件数据。

最原始的获取文件并上传(不做任何本地校验、过滤等处理),仅需要在input.onchange的时候读取fileList对象添加到FormData中递交即可。

假设仅上传一个文件:

<input type="file" id="upload" />
var uploadField = document.getElementById('upload');

uploadField.addEventListener('change', function () {
  var file = this.files[0];
  var fileData = new FormData();

  formdata.append('files', file, file.name);

  // POST伪代码
  doPost(url, fileData, callback)

  // 清理当次选择的的文件记录,见备注2 
  this.value = null;
})

备注:

  1. 无论选择多少个文件,返回的都是fileList,仅上传一个文件的话需要fileList[0] 读取

  2. change事件自身会记录上次选择的文件,不做处理的话选择同一个(一批)文件并不会触发。如果不需要这个特性,则需要手动设置this.value = null

  3. 如果不清理文件记录,已经选择过文件的情况下,再次选择文件但是主动退出,那么依旧会返回一个空的fileList,注意做过滤,否则this.files[0] === undefined

  4. fileList[i]具有以下可读属性,如果需要做校验等处理会用到:

    1. name
    2. size
    3. type
    4. lastModified
    5. lastModifiedDate
    6. webkitRelativePath 仅Mac平台Chrome测试,IE下应该是其他的命名,待补充
  5. HTML部分中,input支持多选multiple及文件格式过滤accept属性。但是需要注意,多选情况下设置accept不要用通配符,会造成触发选择文件时的卡顿。比如:

    <input type="file" multiple accept="image/*" />
    

    最好写成

    <input type="file" multiple accept="image/jpg, image/jpeg, image/png, image/gif" />
    
  6. accept属性仅能在用户默认行为下进行过滤,当用户自行切换文件选择方式时,此规则会被绕过,所以 完整流程需要对fileList进行过滤!

    改变文件选择操作窗口

图片上传

图片上传场景,一般包括以下三种:

  1. 直接上传
  2. 本地预览后直接上传
  3. 本地预览并做裁剪等操作再上传

直接上传即基础部分,不再说明。

本地预览后上传

图片文件转为base64Data

本地预览图片,需要用到FileReader读取图片信息,把file对象转为base64data。img标签支持src的值为base64data字符串。

<input type="file" id="upload" />
<div id="preview"></div>
var uploadFiled = document.getElementById('upload');
var previewArea = document.getElementById('preview');

uploadFiled.addEventListener('change', function () {
  var file = this.files[0];
  var reader = new FileReader();
  var dataOfImage;

  reader.onload = function () {
    dataOfImage = reader.result;

    previewArea.innerHTML = '<img src="' + dataOfImage + '" />';
  };

  reader.readAsDataURL(file);

  this.value = null;
})

Demo

读取图片宽高信息

大多实际场景,产品汪都会要求限定上传图片的尺寸,那么就需要想办法获取原始图片的宽高信息。以往一般通过flash检测,现在则可以通过Image.onload来获取图片信息:

var maxWidth = 800;
var maxHeight = 800;

reader.onload = function () {
  var image = new Image();
  var dataOfImage = reader.result;
  var imageWidth;
  var imageHeight;

  image.onload = function () {
    imageWidth = this.naturalWidth;
    imageHeight = this.naturalHeight;

    if ((imageWidth < maxWidth) || (imageHeight < maxHeight)) {
      previewArea.innerHTML = '<img src="' + dataOfImage + '" />';
    } else {
      window.alert('图片尺寸应小于 ' + maxWidth + ' x ' + maxHeight);
    }
  };

  image.src = dataOfImage;
};

Demo

iOS图片翻转检测及处理

大多数图片都可以通过上面两步完成。但是有些图片,比如 这张 这张 (下载到本地试验),会发现和在电脑上预览的不同,发生了翻转。这是因为iOS设备拍的照片会自带镜头方向信息,电脑上预览会自动修正到正常方向,但是在网页中并没有这种智能处理,这需要我们手动完成。

要完成检测及处理,需要用到两个组件:

  1. exif.js
  2. ios-imagefile-megapixel

exif.js用来读取图片文件的翻转信息Orientation。返回值为数字或者undefined,直接读取input.onchange返回的file对象,不需要读取转换过的base64data:

var orientation;

EXIF.getData(file, function () {
  orientation = EXIF.getTag(this, 'Orientation');
});

注:exif.js仅支持读取.jpg.tiff的信息,其他格式图片是没有exif信息的,返回undefined。

ios-imagefile-megapixel.js则相对复杂,这个插件会通过canvas读取原图,根据前者拿到的Orientation,在新的中转canvas上重新排列每个像素点,之后 把结果绘制在指定的img元素、Image对象或者Canvas 上。注意暂时没发现这个插件提供直接导出base64data的方法,需要指定好绘制对象。一般使用render方法:

var mpImg = new MegaPixImage(file);

mpImg.render(targetImage, { orientation: 1, quality: 0.8 });

直接读取file对象,传入通过exif.js拿到的Orientation值,并设置导出jpeg时希望的压缩比例。几种不同场景示例:

绘制到DOM中指定图片元素上:
<img id="image" />
var drawImgWithMegapix = function (file, orientation, quality) {
  var mpImg = new MegaPixImage(file);
  var targetImage = document.getElementById('image');

  mpImg.render(targetImage, { orientation: orientation, quality: quality });
};
绘制到指定canvas:
<canvas id="canvas"></canvas>
var drawCanvasWithMegapix = function (file, orientation, quality) {
  var mpImg = new MegaPixImage(file);
  var targetCanvas = document.getElementById('canvas');

  mpImg.render(targetCanvas, { orientation: orientation, quality: quality });
};
绘制到指定的JS的Image对象上

这种方法可以避免DOM中提前插入<img src="" />所带来的一些问题:

  1. 图片信息未获得之前,需要做一些前置处理,比如CSS提前定义图片宽高的情况下,空图片会有边框
  2. 某些浏览器下空src的图片会产生一个404请求

在这里,render方法相当于设置了image.src:

<p id="imgWrapper"></p>
var setImageWithMegapix = function (file, orientation, quality) {
  var mpImg = new MegaPixImage(file);
  var imageObj = new Image();
  var imgWrapper = document.getELementById('imgWrapper');

  imageObj.onload = function () {
    // 前置过滤blabla

    // 把image对象插入DOM节点。注意image对象不是DOM节点,不能使用innerHTML。
    // 允许重复选择文件的话,注意插入前先清空旧节点!
    imgWrapper.appendChild(imgaeObj);

    // 对图片进行后续设定
    imgWrapper.querySelector('img').width = '500';
  };

  mpImg.render(imageObj, { orientation: orientation, quality: quality });
};

备注:

  1. ios-imagefile-megapixel的render()目标为<img />new Image()时,图片的src都为base64data
  2. FileReader和Image的onload都是异步,需要注意顺序

Demo

上传

基础部分的上传,直接把file对象塞进FormData发送即可,但是对于iOS图片进行过处理的,则需要考虑上传的是处理过翻转的本地图片还是源文件。这取决于公司自己的后端程序情况及产品需求:

  1. 服务器是否需要留存原图
  2. 后端程序是否有类似ios-imagefile-megapixel的图片处理方案

ios-imagefile-megapixel处理过的图片已经抹掉了exif信息。类似摄影之类网站,不仅对照片拍摄时的相机参数等原始信息有保存需求,很可能还需要提供原始文件下载,这需要保留源文件上传,而不是处理过的文件。这种情况下,如果后端有类似处理方案,直接上传源文件即可;如果后端没有,则需要同时上传双份文件。

如果产品对源文件信息无需求,那么前端只需把处理过的图片上传。

本地处理过的图片不再是file对象,而是base64data,这里需要转化为二进制供上传:

var base64ToBlob = function (base64Data, imageType) {
  // imageType为保存文件的格式字符串,如'image/jpeg'。
  // 一般保持和源文件格式相同,通过input.change的时候读取file[i].type即可
  var blobOfBase64Data;
  var imgStringArray;
  var blob;
  var i;

  blobOfBase64Data = base64Data.split(',')[1];
  blobOfBase64Data = window.atob(blobOfBase64Data);

  imgStringArray = new Uint8Array(blobOfBase64Data.length);

  for (i = 0; i < blobOfBase64Data.length; i++) {
    imgStringArray[i] = blobOfBase64Data.charCodeAt(i);
  }

  blob = new Blob([imgStringArray], { type: imageType });

  return blob;
};

base64ToBlob(base64Data, imageType)处理后的对象,可以像input.onchange获取的file对象一样,通过FormData.append()直接构造表单内容。

本地预览并做裁剪等操作再上传

裁剪多用于头像等场合,一般结合拖动等操作。和普通的预览上传相比,需要通过canvas实现,主要用到两个API:

  1. canvas.getContext('2d').drawImage() 提供把图片来源(<img />或者另一个canvas)的一部分绘制到canvas上的功能,即裁剪
  2. canvas.toDataURL() 提供把canvas转化为base64data的功能,方便最终转换和递交

基础实现

理论上,一个canvas就可以完成功能,大致流程为:

<img id="originImg" />
<canvas id="canvas"></canvas>
<img id="previewImg" />
// 原始图片,已经通过其他函数获取到图片文件并赋值了src
var originImg =  document.getElementById('originImg');

// 预览图片,供生成预览图片用
var previewImg = document.getElementById('previewImg');

// 裁剪出的区域相对于原图的坐标及尺寸
var clippedWidth;
var clippedHeight;
var clippedX;
var clippedY;

// 拖动选区交互过程中,计算出前面四个参数
// ...blablabla

var canvas = document.getElementById('canvas');
var canvasCtx = canvas.getContext('2d');

// canvas最终需要导出成图片,所以宽高设置成和裁剪出的区域一致
canvas.width = clippedWidth;
canvas.height = clippedHeight;

canvasCtx.drawImage(
  originImg, clippedX, clippedY, clippedWidth, clippedHeight,
  0, 0, clippedWidth, clippedHeight
)

// canvas.toDataURL()参数为图片格式字符串及压缩率,同上文base64ToBlob方法。返回值为base64data
previewImg.setAttribute('src', canvas.toDataURL(imageType, 0.8));

// 后续的对toDataURL()返回的base64data处理及上传等操作
// ...blablabla

这样的处理,可以 工作,但在拖拽中需要频繁绘制和导出图片,会带来一系列问题:

  1. 初始src为空的<img>标签的问题
  2. previewImg会随着拖拽不停的改变src,这会在网络调试面板中生成大量请求。虽然都是本地内容可以不考虑加载延时,但也对调试带来一些影响
  3. 原则上,越小的canvas的绘制性能越高。但有些场景,作为预览的图片尺寸,和实际需要保存的尺寸不同,比如手机端可能需要存储的是@2x或者@3x的,而预览区实际上只需要@1x即可。
  4. 有的产品会有多份不同大小尺寸的预览图
  5. canvas.toDataURL()的性能并不高,裁剪类交互大多都有独立的递交按钮,实际上只需要最终递交的时候执行一次即可

实际改进

实际场景中,为了性能或者产品需求,可能用到多个canvas:

  1. 承载原图信息的originImageCanvas
  2. 临时做中转缓存处理的tmpCanvas
  3. 用作展示实时预览裁剪后图片效果的previewCanvas(可能有多个)
originImageCanvas

仅仅用来1:1保存原始图片信息,以及最终导出裁剪后的图片数据使用

previewCanvas

使用canvas可以避免<img src="">带来的问题。canvasCtx.drawImage()可以直接读取另一个canvas,不再牵涉原始图片本身。

tmpCanvas

用作数据中转,可省略,但是建议使用,方便处理previewCanvas的尺寸问题。以及,已获得的资料显示,游戏开发等经常使用 离屏canvas 来解决性能问题。

总体流程为:

  1. input.onchange导出file
  2. FileReader导出base64data
  3. 判断是否iOS图片并给originImage赋值
  4. originImageCanvas导入originImage备用
  5. tmpCanvas设置为预览区的宽高,拖动时根据坐标和尺寸绘制裁剪后区域
  6. previewCanvas直接从tmpCanvas绘制内容
  7. 预览完毕确定递交时,从originImageCanvas导出图片

其他需要注意的细节:

拖动时的计算精度及浮点数取整处理

canvas绘图,如果绘制了一张小于canvas本体大小的内容进去,导出后的图片会带有黑边。

一般情况下,显示原始图片的区域都是根据UI固定的,显示区域比原始图片小的话,原始图片就需要缩放进去显示。这时候,拖动选区对应到最终实际图片上的裁剪区域,就会遇到比例换算及浮点数取整的问题。

比如,一张300x400的原始图片,原始图预览区域是160x160,拖动区70x70,按照默认的图片和拖动区都居中,那么缩放后的原始图为120 x 160,拖动区域的60x60换算过来对应原图裁剪出来区域的则是

{
  width: 175,   // 70 / (160/400)
  height: 175,  // 70 / (160/400)
  x: 62.5,      // ((120 - 70) / 2) / (160 / 400),
  y: 112.5,     // ((160 - 70) / 2) / (160 / 400),
}

而实际中的原始图片大多数并不是相对容易换算的300x400,所得出的小数部分只会更复杂。

另外,出于性能考虑,canvas计算需要避免使用浮点数坐标

图片格式校验

最早一版使用正则校验:

var isImageReg = /(.*)+\.(jpg|jpeg|gif|png)$/i;

if (isImageReg.test(imageName) === false) {
  // 异常操作
}

但是发现文件名过长且 文件名不匹配 时,浏览器会卡死。最终换成常规的基于字符串拼接的校验。

var allowFiletypeList = ['jpg', 'jpeg', 'gif', 'png'];

var isImageFile = function (fileName) {
  var fileTypeString = fileName.split('.').pop().toLowerCase();

  return (allowFiletypeList.indexOf(fileTypeString) > -1);
};
同一场景多个input:file

比如裁剪头像等业务场景,经常会有初始状态的“选择图片”和更换图片的“换一张”按钮,两者都可以触发选图。但是两者的files对象是相互独立的,在做相同文件排查等场合就需要额外处理。

如果有需求,可以使用统一的input标签,界面层想办法在所有用到的地方触发此input的click事件,比如jQuery的.trigger()方法,或者直接input.click()

这里有个性能问题,直接input.click()触发,浏览器有时候会卡顿。也可以使用另一种方法变通实现:

<label><input type="file" /></label>

或者

<label for="inputField"></label>
<input type="file" id="inputField" />

这是利用了HTML原生的特性,label可以和input绑定,只需要在外部调用label.click()即可。这样同一业务的所有上传都使用同一控件,方便进行files对象管理。

移动端

常规的移动端浏览器中,图片上传和处理方式和Web端基本一致,但是有不少细节差异。

文件选择 - 流程

移动端的图片选择,不只是相册中文件一个途径,还多了相机直接拍摄等,所以交互从Web端的单步文件选择窗口变为两步,并且 不可人工干预

  1. 选择渠道

    上传文件途径选择

  2. 选择文件

    上传文件路径选择

注意第一张图,里面多了个摄像机的选项。这里和Web端不同,Web端为了降级文件筛选时的消耗,会把指定的文件具体列出来:

<input type="file" accept="image/jpg, image/jpeg, image/png, image/gif" />

而移动端里,某些浏览器不一定支持多规则,要不显示摄像头,只能使用通配符写法:

<input type="file" accept="image/*" />

并且,单独指定某一类型的文件也是无效的,比如指定只选择jpg格式文件:

<!-- 依旧可以选择到jpg之外的文件,等同于accept="image/*" -->
<input type="file" accept="image/jpg" />

这意味着,移动端要过滤掉摄像机,可能只有设定选择所有图片文件,拿到图片后再做类型过滤。

文件选择 - 多选

<input type="file" accept="image/*" multiple>

很遗憾,文件多选的功能,在Android上不能生效。参见caniuse的统计

(测试环境:Google Nexus 5,Android 6.0.1,Chrome 55.0.2883.91)

微信

微信环境中,普通的HTML5选择文件可以使用,但是不能直接定位到微信APP自己内部整理的相册。以我的手机为例,要选择微信的图片,需要进入“照片/图片”,然后选择“Weixin“文件夹。

微信官方提供的有JSSDK,可以调用APP功能,绕过常规HTML5的交互和多文件选择限制。

具体功能参见微信JSSDK官网文档

需要额外说明的是,微信JSSDK拿到的图片,如果要通过canvas做裁剪,必须走一套完整的流程:

  1. 选择手机上图片
  2. 上传到微信服务器
  3. 自家服务器从微信服务器拉取
  4. 本地image对象下载自家服务器的图片,读取到canvas中

因为通过JSSDK选取图片,拿到的路径是微信APP定义的在图片系统中的路径协议 weixin:// ,这对于canvas来说跨域了。

这样的完整流程,需要额外注意loading的设计及处理,因为耗费在网络传输上的时间太多了。

最后更新时间: 2017-02-17 18:38:56