前端对图片进行本地压缩预览并上传实践
博客专区 > GaiSama 的博客 > 博客详情
前端对图片进行本地压缩预览并上传实践
GaiSama 发表于1个月前
前端对图片进行本地压缩预览并上传实践
  • 发表于 1个月前
  • 阅读 1228
  • 收藏 188
  • 点赞 12
  • 评论 10

腾讯云 十分钟定制你的第一个小程序>>>   

摘要: 正好项目碰到这个需求,花了一下午基本搞定,记录一下实现步骤以及一些需要注意的坑,本实例后端使用node express,Multer作为处理multipart/form-data的中间件,完整代码:https://gitee.com/gaisama/codes/xwus19y6crjnm0vf25dza10

相信做过前端的小伙伴们都写过图片上传,最简单的方式是通过表单提交,一个<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接口。

简单来说,图片预览、压缩和上传主要分这么几步:

  1. 给<input type="file">添加onchange事件,在事件回调中获取元素的files属性;
  2. 创建Image对象,并添加onload事件回调;
  3. 把File对象(File继承Blob)转化为blob url,并赋给Image对象的src属性
  4. 在Image对象的onload回调中创建canvas画布,并将图片写入画布
  5. 通过canvas对象的toDataURL方法,以指定的输出质量生成data url(本质是base64字符串)
  6. 有了base64,我们就可以通过一定的算法将其还原为二进制对象(Blob对象),或者通过canvas的toBlob来输出blob
  7. 最后将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字符串,后台对其进行转换)。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了。

完整代码戳这里

共有 人打赏支持
GaiSama
粉丝 21
博文 17
码字总数 32892
评论 (10)
中柠檬
写得不错
GaiSama

引用来自“中柠檬”的评论

写得不错
那不点个赞嘛:laughing:
蓝水晶飞机
canvas 有一个可以指定图片输出压缩质量的数值参数吧!
GaiSama

引用来自“蓝水晶飞机”的评论

canvas 有一个可以指定图片输出压缩质量的数值参数吧!
是的,这里的压缩就是基于这个实现的
土豆哥哥好
收藏了
孤单的不同世界
gzip
GaiSama

引用来自“孤单的不同世界”的评论

gzip
gzip编码跟这篇文章的压缩似乎不是一回事。。:sweat_smile:
OSC_BmJpzF
表示已经放弃base64上传图片,部分手机获取不到
GaiSama

引用来自“OSC_BmJpzF”的评论

表示已经放弃base64上传图片,部分手机获取不到
能提供一下手机型号和操作系统嘛?我测试了部分机型,除了性能堪忧外没碰到其他问题
双曲线

引用来自“中柠檬”的评论

写得不错

引用来自“GaiSama”的评论

那不点个赞嘛:laughing:
不止点赞,还搜藏了
×
GaiSama
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: