相信做过前端的小伙伴们都写过图片上传,最简单的方式是通过表单提交,一个<input type="file">加一个<button type="submit">,外面包一层form就搞定了。(button默认的type是submit,这里可以省略,之所以特别写出来,是因为以前碰到过form标签里面写了一个<button>标签,一点击就莫名奇妙地把表单提交了,希望大家引以为戒)
但是表单提交会刷新整个页面,于是有心的朋友为了做无刷新的表单提交,可能就会搞一个隐藏的iframe,然后将form标签的target指向这个iframe,这样用户就感知不到页面的刷新。
更细心点的朋友可能会在<input type="file">加上一个accept="image/*"属性,使文件选择框只能选择图片,不过如果你在chrome上这样写可能会碰到窗口打开非常慢的情况,并不是电脑卡,根本原因是当打开文件选择器时,chrome会访问谷歌服务器来拉取图片的mime-type,但是因为天朝的科学上网机制,谷歌当然是访问不了的,所以会有几秒连接超时前的卡顿,如果你有速度够快的梯子,应该可以避免这种情况。比较适应国情的方法是直接在accept里面写上你需要的mime-type,只要是在chrome的白名单里面,就不会去访问谷歌啦,比如:
<input type="file" name="imgFile" accept="image/png, image/jpeg, image/gif">
不过之前测试碰到一个问题,三星手机上如果这么写
<input type="file" name="imgFile" accept="image/png, image/jpeg, image/gif" capture="camera">
是无法调起手机照相机的。还必须得accept="image/*"才行。
如果公司的前端项目打算上html5了,或许拥抱xhr2是更好的选择。xhr2即XMLHttpRequest Level 2,新的规范相较xhr的初版增加了许多有用的新特性,本次实践主要用到FormData和Blob接口。
简单来说,图片预览、压缩和上传主要分这么几步:
- 给<input type="file">添加onchange事件,在事件回调中获取元素的files属性;
- 创建Image对象,并添加onload事件回调;
- 把File对象(File继承Blob)转化为blob url,并赋给Image对象的src属性
- 在Image对象的onload回调中创建canvas画布,并将图片写入画布
- 通过canvas对象的toDataURL方法,以指定的输出质量生成data url(本质是base64字符串)
- 有了base64,我们就可以通过一定的算法将其还原为二进制对象(Blob对象),或者通过canvas的toBlob来输出blob
- 最后将blob对象append进FormData,通过ajax来post到服务器即可
觉得so easy?咱们增加点难度,要求代码可以指定图片最终压缩后的大小以及尺寸(锁定宽高比),okay,下面来实际操作一下,整个项目的结构非常简单:
uploads是文件上传目录,public是静态资源目录
首先用node express搭建一个的服务器,Multer作为处理 multipart/form-data 的中间件,后端的代码一共就这么点,我就不多说了:
express.js:
const express = require('express')
const multer = require('multer')
var storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, './uploads')
},
filename: function (req, file, cb) {
cb(null, Date.now() + '-' + file.originalname)
}
})
const upload = multer({ storage: storage })
const app = express()
app.get('/file/:name', function (req, res, next) {
res.sendFile(req.params.name, { root: __dirname + '/public/' })
})
app.post('/upload', upload.single('avatar'), function (req, res, next) {
res.json({ msg: 'upload over' })
});
const server = app.listen(3000, function () {
const host = server.address().address
const port = server.address().port
console.log('server listening at %s:%s', host, port)
})
接着写我们的前端代码,先创建一个简单的html页面
test.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>
</head>
<body>
<input type="file" name="file" accept="image/png, image/jpeg, image/jpg" id="file">
<img src="" alt="" id="preview">
<button type="button" id="upload">上传</button>
<script>
const fileEle = document.getElementById('file')
const uploadEle = document.getElementById('upload')
const previewEle = document.getElementById('preview')
let imgFile = null
function compress(target, quality_size, maxWidth, maxHeight, onSuccess) {
// 这里做压缩
}
fileEle.onchange = function (e) {
// 这里调用compress函数
}
uploadEle.onclick = function (e) {
// 点击上传图片
}
</script>
</body>
</html>
然后在onchange回调中获取file
fileEle.onchange = function (e) {
// 这里调用compress函数
if (fileEle.files.length > 0) {
const file = e.target.files[0]
compress(file, 500, 1000, 1000, function (data) {
previewEle.src = data.dataUrl
imgFile = data.blob
})
}
}
compress是我们正在处理压缩的方法,它接收五个参数:需要压缩的文件,最终压缩的文件大小(KB),最大宽度,最大高度。
接下来编写compress函数进行压缩操作。
let _ctx, _mimeType, _width, _height, _quality, _targetSize, _onSuccess
/**
* 压缩图片文件
* @param target 图片文件
* @param quality_size 文件大小
* @param maxWidth 最大宽度
* @param maxHeight 最大高度
* @param onSuccess 成功回调
*/
function compress(target, quality_size, maxWidth, maxHeight, onSuccess) {
// 这里做压缩
if (typeof target === 'object') { // 首次执行
const file = target
const fileSize = file.size / 1000
_targetSize = quality_size
_mimeType = file.type
_onSuccess = onSuccess
const srcImg = new Image()
const srcImgData = URL.createObjectURL(file)
if (fileSize < _targetSize) {
_onSuccess({ dataUrl: srcImgData, blob: file })
return false
}
srcImg.src = srcImgData
srcImg.onload = function () {
_width = srcImg.naturalWidth
_height = srcImg.naturalHeight
if (_width > maxWidth) {
_height = maxWidth / _width * _height
_width = maxWidth
}
if (_height > maxHeight) {
_width = maxHeight / _height * _width
_height = maxHeight
}
const ratio = _width / _height
_cvs = document.createElement('canvas');
_cvs.width = _width;
_cvs.height = _height;
_ctx = _cvs.getContext("2d")
_ctx.drawImage(srcImg, 0, 0, _width, _height)
var imgData = _cvs.toDataURL(_mimeType, 1);
const imgSize = Math.round(imgData.replace('data:' + _mimeType + ';base64,', '').length * 3 / 4) / 1000
imgSize > _targetSize ? compress(imgData, _targetSize / imgSize) : _cvs.toBlob(blob => { _onSuccess({ dataUrl: imgData, blob: blob }) }, _mimeType, quality)
}
} else if (typeof target === 'string') {
const imgData = target
const newImg = new Image()
const quality = quality_size > 0.9 ? 0.9 : quality_size
newImg.src = imgData
newImg.onload = function () {
_ctx.clearRect(0, 0, _width, _height)
_ctx.drawImage(newImg, 0, 0);
var newImgData = _cvs.toDataURL(_mimeType, quality);
const newImgSize = Math.round(newImgData.replace('data:' + _mimeType + ';base64,', '').length * 3 / 4) / 1000
if (newImgSize > _targetSize) {
compress(newImgData, _targetSize / newImgSize)
} else {
_cvs.toBlob(blob => { _onSuccess({ dataUrl: newImgData, blob: blob }) }, _mimeType, quality)
}
}
}
}
之所以先判断target类型是因为首次调用compress时传入的是一个file对象,后面进行递归的时候传入的是dataUrl字符串。函数比较关键的几步需要注意:
① 在递归前我会先去掉imgData的描述头
imgData.replace('data:' + _mimeType + ';base64,', '')
就可以获取base64字符串,根据base64的生成算法(见维基),一个字符代表6位,于是获取base64字符串的长度,然后除6乘8就可以得到这个字符串还原为二进制后的字节数,这就是下面这行代码的原理:
const imgSize = Math.round(imgData.replace('data:' + _mimeType + ';base64,', '').length * 3 / 4) / 1000
② 虽然知道了base64字符串所代表的二进制对象大小,但是要将文件上传,还是需要转化为真正的二进制Blob对象(另一种做法是直接提交base64字符串,后台对其进行转换,这么做的坏处是上传的数据量增大了 1/3)。Blob的构造函数可以接受一个类型数组,通过这个方式我们就可以将base64字符串还原为二进制对象,如果图方便,可以直接使用canvas的toBlob方法来直接生成Blob,不过ios似乎暂时并不支持原生的toBlob,这里可以使用MDN提供的基于toDataURL实现的polyfill:
if (!HTMLCanvasElement.prototype.toBlob) {
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
value: function (callback, type, quality) {
var binStr = atob( this.toDataURL(type, quality).split(',')[1] ),
len = binStr.length,
arr = new Uint8Array(len);
for (var i = 0; i < len; i++ ) {
arr[i] = binStr.charCodeAt(i);
}
callback( new Blob( [arr], {type: type || 'image/png'} ) );
}
});
}
③ 上面的polyfill用到了window.atob这个js原生方法,其作用是解码一个已经被base-64编码过的数据,相对应的还有一个window.btoa函数可以将ascii字符串或二进制数据转换成一个base64编码过的字符串,该方法不能直接作用于Unicode字符串(原因及解决办法见MDN的API文档),目前两个方法也都有兼容性问题,可以通过另一个polyfill来解决:
/**
* Base64编/解码
* @type {{characters: string, encode: Base64.encode, decode: Base64.decode}}
*/
let Base64 = {
characters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=',
/**
* 对字符串编码
* @param {string} string
* @returns {string}
*/
encode: function (string) {
let characters = Base64.characters
let result = ''
let i = 0
do {
let a = string.charCodeAt(i++)
let b = string.charCodeAt(i++)
let c = string.charCodeAt(i++)
a = a || 0
b = b || 0
c = c || 0
let b1 = (a >> 2) & 0x3F
let b2 = ((a & 0x3) << 4) | ((b >> 4) & 0xF)
let b3 = ((b & 0xF) << 2) | ((c >> 6) & 0x3)
let b4 = c & 0x3F
if (!b) {
b3 = b4 = 64
} else if (!c) {
b4 = 64
}
result += characters.charAt(b1) + characters.charAt(b2) + characters.charAt(b3) + characters.charAt(b4)
} while (i < string.length)
return result
},
/**
* 对base64字符串解码
* @param {string} string
* @returns {string}
*/
decode: function (string) {
let characters = Base64.characters
let result = ''
let i = 0
do {
let b1 = characters.indexOf(string.charAt(i++))
let b2 = characters.indexOf(string.charAt(i++))
let b3 = characters.indexOf(string.charAt(i++))
let b4 = characters.indexOf(string.charAt(i++))
let a = ((b1 & 0x3F) << 2) | ((b2 >> 4) & 0x3)
let b = ((b2 & 0xF) << 4) | ((b3 >> 2) & 0xF)
let c = ((b3 & 0x3) << 6) | (b4 & 0x3F)
result += String.fromCharCode(a) + (b ? String.fromCharCode(b) : '') + (c ? String.fromCharCode(c) : '')
} while (i < string.length)
return result
}
}
window.btoa = Base64.encode
window.atob = Base64.decode
④ 本人对图片压缩算法并不了解,canvas的toDataURL方法指定输出质量原理是啥也不清楚,所以我用了一个比较“2”的办法,循环压缩图片,直到输出大小符合我们的要求——这个做法似乎十分蹩脚,如果大佬有更好的方法望指教。
最后就可以将我们的图片上传了:
uploadEle.onclick = function (e) {
// 点击上传图片
if (fileEle.files.length > 0) {
const formData = new FormData()
console.log(fileEle.files[0])
imgFile && formData.append('avatar', imgFile, 'avatar.' + _mimeType.split('/')[1])
formData.append('user', '666666')
$.ajax({
type: 'post',
url: '/upload',
data: formData,
contentType: false,
processData: false,
success: function (res) {
console.log(res)
},
dataType: 'json'
})
}
}
FormData.append方法的第一个参数是fieldName(字段/参数名),第二个是待提交的数据,第三个是originalName(文件名),案例图简单,就直接使用jQuery了。
来源:oschina
链接:https://my.oschina.net/u/1389094/blog/1534940