前端上传图片已经是一个老生常谈的话题了,本人也在工作中多次遇到这类需求,虽然现在市面上现有的插件也很多,但是,本着自己造轮子的想法,打算自己搞一波,也是对相关知识的一个总结和梳理。
整体体思路
- 利用type='file' input标签来获取图片的files对象。
- 将files对象通过FileReader来转化为base64格式。
- 通过canvas.drawImage()对图片进行压缩处理。
- 将base64 通过Exift.js插件进行图片校验,将发生旋转的图片通过canvas进行矫正。
- 将base64转为blob二进制对象,进行上传。
01.什么是files对象?
通过以下简单的代码,我们可以获取一个fileList的类数组
类数组: 1)拥有length属性,其它属性(索引)为非负整数(对象中的索引会被当做字符串来处理); 2)不具有数组所具有的方法!(如:push,forEach,filter,reduce等等)
...
<input type="file" @change="handleChange"/>
...
...
handleChange(e){
console.log(e.target.files)
}
...
得到以下对象:
- name:图片的名字
- lastModified:文件修改的时间戳
- size: 文件大小,单位:B
- type: 文件的类型
- 等等
我们会发现,里面记录了文件的名字,修改时间,大小和类型等,可知这个对象只存放了一些文件的信息,相当于是本地文件的索引,并不是把文件放到input中了,上传文件时它会再去找到实际的本地文件。注意这是一个只读对象!,所以不要尝试直接去修改它.
02.关于FileReader
前面说了,既然无法直接修改fileList对象,那如果我们需要对原始文件做处理,诸如压缩等操作,又该如何?这里我们可以使用FileReader.readAsDataURL(file),来获得URL格式的Base64字符串以表示所读取文件的内容。我们再通过处理base64,来达到我们的目的。
FileReader概念: FileReader 对象允许Web应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File 或 Blob 对象指定要读取的文件或数据。
所以我们可以将刚才得到的fileList对象做如下处理
...
if (files.length === 0) return;
let file = files[0]
console.log(file, 'file')
let fileReader = new FileReader()
fileReader.onloadstart = () => {
// 如果类型不符合
if (this.accept !== 'image/*' && !this.accept.includes(file.type.toLowerCase())) {
fileReader.abort()
console.error('格式错误了-->', file.type.toLowerCase())
}
}
fileReader.onload = () => {
// 这就是我们这一步需要的base64
let base64 = fileReader.result
}
fileReader.onprogress = (e) => {}
fileReader.readAsDataURL(file)
...
03.压缩
前面通过解释fileList的概念,我们知道,文件都是有大小的(废话),我们可以通过 .size来获取源文件的尺寸。我们都知道,随着如今手机像素越来越高,图片越来越清晰,图片的尺寸也水涨船高,变得越来越大,如果在网速比较差的环境下,上传图片会变成这样:
base64通过canvas.drawImage()将图片绘制在canvas画布上,然后再通过canvas.toDataURL('image/jpeg', this.quality)来获取压缩后的base64。
canvas: html5新增的元素,可用于通过使用js的脚本来绘制图形,创建动画(相信大家都用过)。
代码如下:
...
let canvas = document.createElement('canvas')
let ctx = canvas.getContext('2d');
let image = document.createElement('img');
image.src = base64;
image.onload = () => {
canvas.width = imageWidth
canvas.height = imageHeight
ctx.drawImage(image, 0, 0, imageWidth, imageHeight);
//质量压缩成之前的0.5
canvas.toDataURL('image/jpeg', 0.5)
}
...
关于drawImage方法
我觉得这是个很强大的方法,不仅可以用来绘图,更重要的是还可用于前端图片的裁剪,由于后面的旋转画布也用到了这个方法,所以这里稍微展开下。
ctx.drawImage(img,startX,startY,croppedWidth,croppedHeight,locationX,LocationY,finalCanvasWidth,finalCanvasHeight),虽然参数看上去非常多,但是,其实也不难发现规律
在实践中发现,该方法实际上相当于有2中的模式,我们暂且就简单叫 5参数模式和9参数的模式 具体参数说明:
-
img:也就是将被呈现到画布上的资源,可以是图片,也可以是视频。
-
startX/startY: 这个参数,个人理解有2种模式区别所在。
5参数模式 表示图片放置在画布上的位置(canvas坐标);
9参数模式 表示在图片上开始裁剪的位置(图片坐标)
-
croppedWidth/croppedHeight:在图片上裁剪的范围
-
locationX/locationY:在canvas画布上开始绘图的位置,实际上代替startX/startY在5参数模式下的角色
-
finalCanvasWidth/finalCanvasHeight:在canvas画布上绘图的范围(处理不当会造成图像变形扭曲)
所以,我们会发现,drawImage中,除了第一个参数外,其余参数都和坐标与长度相关,而且在9参数模式中,前2对长度入参(startX/startY和croppedWidth/croppedHeigh)是和被裁剪的图片相关,后两对长度入参(locationX/locationY和finalCanvasWidth/finalCanvasHeight),都和画布相关。
下面简单介绍下基本的使用:
- 我们可以忽略后面2对参数,也能绘制图片,但要处理好原图和画布的大小关系,canvas默认尺寸为
300 * 150,如果我们找一张非常大的图片1280 * 853
...
<!--绘制整张原图到画布上-->
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight)
...
可以看出,我们虽然绘制出了图片,但却并不完整,因为我们相当于获取了整张图范围内的像素信息img.naturalWidth, img.naturalHeight,但却没有很好处理它,只是将它简单丢到300 * 150的矩形中,剩余部分一概不考虑。后果可想而知,我们只获取了左上角一点点图片,其他都没了,削足适履。
- 所以,本案例矛盾点在于我们的要放的图太大,而"容器"太小,所以解决办法只有2个,要么缩小图片,要么扩大容器。
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, 0, 0, canvas.width, canvas.height)
300*150的"容器"中,就像橡皮人一样变形。。
- 所以还要考虑图片长宽比例问题,我们计算原图的长宽比例
img.naturalWidth / img.naturalHeight,再计算出"容器"本应有的尺寸
let radio = img.naturalWidth / img.naturalHeight
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, (canvas.width - canvas.height * radio) / 2, 0, canvas.height * radio, canvas.height)
- 所以按照将"容器"变大的想法,我们有了如下处理:
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight)
04.图片旋转问题
正当一切看似很顺利,提测之后测试同学传来'噩耗',图片旋转了 翻车现场:
-
手机(android)竖拍
-
手机(ios)竖拍
经过简(反)单(复)测(折)试(磨)之后,发现是ios自身的bug导致的,总结如下: -
当用ios手机竖着拍摄的时候,照片会逆时针旋转90度;
-
当用ios手机横向(home按键/手机底部在右边)拍摄的时候,照片正常;
-
当用ios手机横向(home按键/手机底部在左边)拍摄的时候,照片会针旋转180度;
-
当用ios手机倒着拍照(我知道一般人不会,除非你非正常拍摄。。)的时候,照片会顺时针旋转90度;
解决思路: 既然照片旋转了,那自然想到的就是矫正回来,就像既然得了近视,晶状体无法将光正确聚焦在视网膜上,我们就会用眼镜矫正光的角度。而不同的"度数"则对应不同矫正方式,而要矫正之前首先要知道是否出了问题,出的是哪种问题,我这里使用的检测插件是exif-js,利用getTag方法来获取图片旋转的度数,从而采取不同措施。
...
const EXIF = require("exif-js");
EXIF.getData(file, function () {
let Orientation = EXIF.getTag(this, 'Orientation');
// 通过Orientation的值来判断照片是否正常以及错误旋转的度数
// Orientation === 6 :逆时针旋转了90度
// Orientation === 3 :旋转180度
// Orientation === 8 :顺时针旋转了90度
// Orientation === 1 或者 undefined :正常
})
...
- 照片逆时针旋转90度情况下:
...
<!--图片既然旋转90度,那么画布长宽肯定互换位置了-->
canvas.width = imageHeight;
canvas.height = imageWidth;
将图片顺时针旋转90度
ctx.rotate(Math.PI / 2);
<!--之前有说过drawImage有两种模式,这是5参数模式-->
ctx.drawImage(img, 0, -imageHeight, imageWidth, imageHeight);
<!--9参数模式-->
ctx.drawImage(img, 0, 0, imageWidth,imageHeight, 0, -imageHeight, imageWidth, imageHeight);
<!--其实可以看得出来9参数模式中,6,7号参数代替了5参数模式中2,3号参数的位置-->
...
2.照片旋转180度情况下:
...
<!--画布长宽,注意180度就不要互换了-->
canvas.width = imageWidth;
canvas.height = imageHeight;
<!--将画布再转180度-->
ctx.rotate(Math.PI);
<!--5参数模式-->
ctx.drawImage(img, -imageWidth, -imageHeight, imageWidth, imageHeight);
<!--9参数模式-->
ctx.drawImage(img, 0, 0, imageWidth,imageHeight, -imageWidth, -imageHeight, imageWidth, imageHeight);
...
3.照片顺时针旋转了90度情况下:
...
<!--旋转90度,长宽互换-->
canvas.width = imageHeight;
canvas.height = imageWidth;
<!--将图片逆时针旋转90度-->
ctx.rotate(3 * Math.PI / 2);
<!--5参数模式-->
ctx.drawImage(img, -imageWidth, 0, imageWidth, imageHeight);
<!--9参数模式-->
ctx.drawImage(img, 0, 0, imageWidth,imageHeight, -imageWidth, 0, imageWidth, imageHeight);
...
05.转为blob对象上传
经过了刚才一系列的步骤,我们拿到的仅仅是一个base64,我们还需要转为blob二进制数据然后上传到服务端:
let arr = base64.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
Blob([u8arr], { type: mime });
...
完整代码
好了,说了这么多,现在贴出完整代码如下:
class Upload {
constructor({ el, accept, multiple, onUpload, quality }) {
this.el = el || ''
this.accept = accept || 'image/*'
this.multiple = multiple || false
this.quality = quality || 1
this.beforeUpload = (e) => { console.log(e) }
this.onProgress = (e) => { console.log('progress->', e) }
this.onLoad = (result) => {
onUpload(result)
}
this.onError = () => { }
this.init()
}
init () {
// 如果存在节点
if (this.el) {
this.el = typeof this.el === 'object' ? this.el : document.querySelector(this.el)
}
this.render()
this.watch()
}
// 渲染节点
render () {
let fragment = document.createDocumentFragment(),
file = document.createElement('input');
console.log(file, 'file')
file.type = this.accept
file.setAttribute('type', 'file');
file.setAttribute('multiple', this.multiple)
file.setAttribute('accept', this.accept)
// 安卓非微信浏览器
// 苹果/安卓(小米手机)手机微信浏览器
// file.setAttribute('capture', 'camera')
file.style.display = "none"
file.className = 'upload__input'
fragment.appendChild(file)
this.el.appendChild(fragment)
}
watch () {
let inputEl = this.el.querySelector('.upload__input');
inputEl.addEventListener('change', () => {
// 伪数组转为数组
let files = Array.from(inputEl.files) // 如同时选择几张图片,则该数组.length>1
// 读取图片
let readImg = () => {
// 图片递归殆尽则终止
if (files.length === 0) return;
let file = files[0]
let fileReader = new FileReader()
fileReader.onloadstart = () => {
// 如果类型不符合
if (this.accept !== 'image/*' && !this.accept.includes(file.type.toLowerCase())) {
fileReader.abort()
this.beforeUpload(file)
console.error('文件格式有误-->', file.type.toLowerCase())
}
}
fileReader.onload = async () => {
let base64 = fileReader.result
let compressedBase64 = await this.compressBase64(file, base64)
let blob = this.base64ToBlob(compressedBase64)
this.onLoad({ blob, base64: compressedBase64 })
files.shift() // 删除第一个
// 递归
readImg()
}
fileReader.onprogress = (e) => {
this.onProgress(e)
}
this.isImage(file.type) ? fileReader.readAsDataURL(file) : fileReader.readAsText(file);
}
readImg()
})
}
// 压缩base64
compressBase64 (file, base64) {
let canvas = document.createElement('canvas')
let image = document.createElement('img');
image.src = base64;
let size = file.size / 1000 / 1024 // b -> MB
console.log(size, 'MB')
this.quality = Math.min(2 / size, 1) // 图片大小限制为2MB以内
console.log(this.quality, 'quality')
return new Promise(resolve => {
image.onload = async () => {
let imageWidth = image.naturalWidth;
let imageHeight = image.naturalHeight;
await this.rotateCanvas(file, image, canvas, imageWidth, imageHeight)
resolve(canvas.toDataURL('image/jpeg', this.quality))
}
})
}
// 旋转画布,防止ios低版本图片旋转问题
rotateCanvas (file, image, canvas, imageWidth, imageHeight) {
let ctx = canvas.getContext('2d');
let Orientation = 1
const EXIF = require("exif-js");
return new Promise(resolve => {
EXIF.getData(file, function () {
// 获取图片信息
Orientation = EXIF.getTag(this, 'Orientation');
console.log(Orientation, 'orient')
switch (Orientation * 1) {
case 6: // 旋转90度
canvas.width = imageHeight;
canvas.height = imageWidth;
ctx.rotate(Math.PI / 2);
ctx.fillStyle = "white"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(image, 0, -imageHeight, imageWidth, imageHeight);
break;
case 3:// 旋转180度
canvas.width = imageWidth;
canvas.height = imageHeight;
ctx.rotate(Math.PI);
ctx.fillStyle = "white"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(image, -imageWidth, -imageHeight, imageWidth, imageHeight);
break;
case 8: // 旋转-90度
canvas.width = imageHeight;
canvas.height = imageWidth;
ctx.rotate(3 * Math.PI / 2);
ctx.fillStyle = "white"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(image, -imageWidth, 0, imageWidth, imageHeight);
break;
default:
// 默认正确的情况下
canvas.width = imageWidth;
canvas.height = imageHeight;
ctx.fillStyle = "white"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(image, 0, 0, imageWidth, imageHeight);
}
resolve()
});
})
}
// 检验是否为图片
isImage (type) {
let reg = /(image\/jpeg|image\/jpg|image\/png)/gi
return reg.test(type)
}
// base64 -> blob
base64ToBlob (base64) {
let arr = base64.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}
}
export default Upload
调用
new Upload({
el: document.querySelector('.upload__btn'),
accept: 'image/*',
multiple: true,
quality: 1,
onUpload ({ blob, base64 }) {
// base64 用于预览
// blob 给后台
}
})