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();
  
  fileData.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 上 Safari 和在电脑上预览的不同,发生了翻转。

这是因为 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 保存原始图片信息,以及最终导出裁剪后的图片数据使用

  • 宽高为 image.onload 返回的 naturalWidth 和 naturalHeight
  • 可设置隐藏,或 document.createElement('canvas') 生成,不插入 DOM 树
  • 最终递交保存时执行一次 originImageCanvas.toDataURL 导出最终图片信息即可。
previewCanvas

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

  • 宽高为 UI 界面上显示的,可以和实际要保存的图片尺寸脱离
  • 尺寸脱离,所以同时存在多个预览尺寸并不受影响
tmpCanvas

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

  • 宽高可以自定,一般等于 previewCanvas 的尺寸(单个预览)或者最大的 previewCanvas 尺寸(多个预览),或等比大小
  • 可设置隐藏或 document.createElement('canvas') 生成,不插入 DOM 树
  • 仅负责从 originImageCanvas 或者 originImage 导入裁剪区域,以及导出到 previewCanvas,不牵涉任何其他的数据流转及存储

总体流程为:

  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 的设计及处理,因为耗费在网络传输上的时间太多了。

Blog