2017 年 3 月 10 日
微信 HTML5 开发中上传图片的技术细节
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;
})
备注:
- 无论选择多少个文件,返回的都是fileList,仅上传一个文件的话需要
fileList[0]
读取 change
事件自身会记录上次选择的文件,不做处理的话选择同一个(一批)文件并不会触发。如果不需要这个特性,则需要手动设置this.value = null
- 如果不清理文件记录,已经选择过文件的情况下,再次选择文件但是主动退出,那么依旧会返回一个空的fileList,注意做过滤,否则
this.files[0] === undefined
- fileList[i]具有以下可读属性,如果需要做校验等处理会用到:
- name
- size
- type
- lastModified
- lastModifiedDate
- webkitRelativePath 仅Mac平台Chrome测试,IE下应该是其他的命名,待补充
- HTML部分中,input支持多选
multiple
及文件格式过滤accept
属性。但是需要注意,多选情况下设置accept不要用通配符,会造成触发选择文件时的卡顿。比如:<input type="file" multiple accept="image/*" />
最好写成
<input type="file" multiple accept="image/jpg, image/jpeg, image/png, image/gif" />
accept
属性仅能在用户默认行为下进行过滤,当用户自行切换文件选择方式时,此规则会被绕过,所以 完整流程需要对fileList进行过滤!
图片上传
图片上传场景,一般包括以下三种:
- 直接上传
- 本地预览后直接上传
- 本地预览并做裁剪等操作再上传
直接上传即基础部分,不再说明。
本地预览后上传
图片文件转为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;
})
读取图片宽高信息
大多实际场景,产品汪都会要求限定上传图片的尺寸,那么就需要想办法获取原始图片的宽高信息。以往一般通过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;
};
iOS图片翻转检测及处理
大多数图片都可以通过上面两步完成。但是有些图片,比如 这张 和 这张 (下载到本地试验),会发现和在电脑上预览的不同,发生了翻转。这是因为iOS设备拍的照片会自带镜头方向信息,电脑上预览会自动修正到正常方向,但是在网页中并没有这种智能处理,这需要我们手动完成。
要完成检测及处理,需要用到两个组件:
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="" />
所带来的一些问题:
- 图片信息未获得之前,需要做一些前置处理,比如CSS提前定义图片宽高的情况下,空图片会有边框
- 某些浏览器下空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 });
};
备注:
- ios-imagefile-megapixel的render()目标为
<img />
或new Image()
时,图片的src都为base64data - FileReader和Image的onload都是异步,需要注意顺序
上传
基础部分的上传,直接把file对象塞进FormData发送即可,但是对于iOS图片进行过处理的,则需要考虑上传的是处理过翻转的本地图片还是源文件。这取决于公司自己的后端程序情况及产品需求:
- 服务器是否需要留存原图
- 后端程序是否有类似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:
canvas.getContext('2d').drawImage()
提供把图片来源(<img />
或者另一个canvas
)的一部分绘制到canvas上的功能,即裁剪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
这样的处理,可以 工作,但在拖拽中需要频繁绘制和导出图片,会带来一系列问题:
- 初始src为空的
<img>
标签的问题 - previewImg会随着拖拽不停的改变src,这会在网络调试面板中生成大量请求。虽然都是本地内容可以不考虑加载延时,但也对调试带来一些影响
- 原则上,越小的canvas的绘制性能越高。但有些场景,作为预览的图片尺寸,和实际需要保存的尺寸不同,比如手机端可能需要存储的是@2x或者@3x的,而预览区实际上只需要@1x即可。
- 有的产品会有多份不同大小尺寸的预览图
canvas.toDataURL()
的性能并不高,裁剪类交互大多都有独立的递交按钮,实际上只需要最终递交的时候执行一次即可
实际改进
实际场景中,为了性能或者产品需求,可能用到多个canvas:
- 承载原图信息的originImageCanvas
- 临时做中转缓存处理的tmpCanvas
- 用作展示实时预览裁剪后图片效果的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,不牵涉任何其他的数据流转及存储
总体流程为:
- input.onchange导出file
- FileReader导出base64data
- 判断是否iOS图片并给originImage赋值
- originImageCanvas导入originImage备用
- tmpCanvas设置为预览区的宽高,拖动时根据坐标和尺寸绘制裁剪后区域
- previewCanvas直接从tmpCanvas绘制内容
- 预览完毕确定递交时,从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端的单步文件选择窗口变为两步,并且 不可人工干预:
- 选择渠道
- 选择文件
注意第一张图,里面多了个摄像机的选项。这里和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做裁剪,必须走一套完整的流程:
- 选择手机上图片
- 上传到微信服务器
- 自家服务器从微信服务器拉取
- 本地image对象下载自家服务器的图片,读取到canvas中
因为通过JSSDK选取图片,拿到的路径是微信APP定义的在图片系统中的路径协议 weixin://
,这对于canvas来说跨域了。
这样的完整流程,需要额外注意loading的设计及处理,因为耗费在网络传输上的时间太多了。
最后更新时间: 2017-02-17 18:38:56