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;
})
备注:
无论选择多少个文件,返回的都是 fileList,仅上传一个文件的话需要 fileList[0]
读取
change
事件自身会记录上次选择的文件,不做处理的话选择同一个(一批)文件并不会触发。如果不需要这个特性,则需要手动设置 this.value = null
如果不清理文件记录,已经选择过文件的情况下,再次选择文件但是主动退出,那么 依旧会返回一个空的 fileList,注意做过滤,否则 this.files[0] === undefined
fileList[i] 具有以下可读属性,如果需要做校验等处理会用到:
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 进行过滤!
图片上传场景,一般包括以下三种:
直接上传即基础部分,不再说明。
本地预览图片,需要用到 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 上 Safari 和在电脑上预览的不同,发生了翻转。
这是因为 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 时希望的压缩比例。几种不同场景示例:
<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 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 });
};
这种方法可以避免 DOM 中提前插入 <img src="" />
所带来的一些问题:
在这里,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基础部分的上传,直接把 file 对象塞进 FormData 发送即可,但是对于 iOS 图片进行过处理的,则需要考虑上传的是处理过翻转的本地图片还是源文件。这取决于公司自己的后端程序情况及产品需求:
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
这样的处理,可以 工作,但在拖拽中需要频繁绘制和导出图片,会带来一系列问题:
<img>
标签的问题previewImg
会随着拖拽不停的改变 src,这会在网络调试面板中生成大量请求。虽然都是本地内容可以不考虑加载延时,但也对调试带来一些影响canvas.toDataURL()
的性能并不高,裁剪类交互大多都有独立的递交按钮,实际上只需要最终递交的时候执行一次即可实际场景中,为了性能或者产品需求,可能用到多个 canvas:
仅仅用来 1:1 保存原始图片信息,以及最终导出裁剪后的图片数据使用
document.createElement('canvas')
生成,不插入 DOM 树originImageCanvas.toDataURL
导出最终图片信息即可。使用 canvas 可以避免 <img src="">
带来的问题。canvasCtx.drawImage()
可以直接读取另一个 canvas,不再牵涉原始图片本身。
用作数据中转,可省略,但是建议使用,方便处理 previewCanvas 的尺寸问题。以及,已获得的资料显示,游戏开发等经常使用 离屏 canvas 来解决性能问题。
document.createElement('canvas')
生成,不插入 DOM 树总体流程为:
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);
};
比如裁剪头像等业务场景,经常会有初始状态的“选择图片”和更换图片的“换一张”按钮,两者都可以触发选图。但是两者的 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 做裁剪,必须走一套完整的流程:
因为通过 JSSDK 选取图片,拿到的路径是微信 APP 定义的在图片系统中的路径协议 weixin://
,这对于 canvas 来说跨域了。
这样的完整流程,需要额外注意 loading 的设计及处理,因为耗费在网络传输上的时间太多了。