我们的场景是前端SPA应用有两个入口,一个是我们自己的微信公众号(可以实现微信授权,调用微信JSSDK),一个是第三方的公众号(需要第三方支持才能实现微信授权),出于统一和方便的考虑,上传照片功能没有使用微信JSSDK里的选择图片和上传图片相关方法,而是用传统页面里上传图片的方式。上传图片常见的有两种方式,一种是上传图片base64数据,一种是上传文件blob对象。这里我们选择的是上传文件blob对象。现在的问题是,服务端尤其是大部分tomcat里配置的post请求允许接收的最大数据量好像是2M,现在手机的像素都很高里,很容易就会拍出来大于2M的照片的,而且就算没有服务端限制,上传2M甚至更大的图片的速度会比较慢,对用户的体验也不好。所以今天趁着有空,自己做了个图片压缩的功能上去。直接上代码吧,下面是封装的压缩图片文件(blob对象)的方法,外部调用这里的compressImage方法(该方法返回的是一个Promise对象):
/** * Created by yakima on 2017/9/30. */ import mode from '@/scripts/mode.js' // 配置参数 const config = { // 不触发压缩行为的最大文件大小(100 * 1024表示100kb),注意,这并不代表压缩后一定会压缩到该大小以下 maxSize: 100 * 1024 } /** * 获取文件大小描述 * @param fileSize * @returns {string} 如8.1M, 100kb */ function getFileSizeDesc (fileSize) { return fileSize / 1024 > 1024 ? (~~(10 * fileSize / 1024 / 1024) / 10) + 'MB' : ~~(fileSize / 1024) + 'kb' } function compress (objImg) { const d = window.document // 用于压缩图片的canvas const canvas = d.createElement('canvas') const ctx = canvas.getContext('2d') // 瓦片canvas const tileCanvas = d.createElement('canvas') const tileCtx = tileCanvas.getContext('2d') // document.getElementsByTagName('body')[0].innerHTML = `<img src="${objImg.src}">` const initSize = objImg.src.length let width = objImg.width let height = objImg.height // 如果图片尺寸大于400万像素,计算压缩比并将大小压至400万以下 let ratio if ((ratio = width * height / 4000000) > 1) { ratio = Math.sqrt(ratio) width /= ratio height /= ratio } else { ratio = 1 } canvas.width = width canvas.height = height // 铺底色 ctx.fillStyle = '#fff' ctx.fillRect(0, 0, canvas.width, canvas.height) // 如果图片尺寸大于100万像素则使用瓦片绘制 let count if ((count = width * height / 1000000) > 1) { // 计算要分成多少块瓦片 count = ~~(Math.sqrt(count) + 1) const tileWidth = ~~(width / count) const tileHeight = ~~(height / count) tileCanvas.width = tileWidth tileCanvas.height = tileHeight for (let i = 0; i < count; i++) { for (let j = 0; j < count; j++) { tileCtx.drawImage(objImg, i * tileWidth * ratio, j * tileHeight * ratio, tileWidth * ratio, tileHeight * ratio, 0, 0, tileWidth, tileHeight) ctx.drawImage(tileCanvas, i * tileWidth, j * tileHeight, tileWidth, tileHeight) } } } else { ctx.drawImage(objImg, 0, 0, width, height) } // 进行最小压缩 const nData = canvas.toDataURL('image/jpeg', 0.6) if (mode.mode !== 0) { console.log('压缩前base64数据长度:' + getFileSizeDesc(initSize)) console.log('压缩后base64数据长度:' + getFileSizeDesc(nData.length)) console.log('base64数据压缩率:' + ~~(100 * (initSize - nData.length) / initSize) + '%') } tileCanvas.width = tileCanvas.height = canvas.width = canvas.height = 0 // document.getElementsByTagName('body')[0].innerHTML = `<img src="${nData}">` return nData } // 获取blob对象的兼容性写法 function getBlob (buffer, format) { let blob try { blob = new window.Blob(buffer, { type: format }) } catch (e) { const blobBuilder = new (window.BlobBuilder || window.WebKitBlobBuilder || window.MSBlobBuilder)() buffer.forEach(function (buf) { blobBuilder.append(buf) }) blob = blobBuilder.getBlob(format) } if (mode.mode !== 0) { console.log(`压缩后Blob对象大小:${getFileSizeDesc(blob.size)}`) } return blob } function getBlobFromBase64Data (base64Data, fileType) { // 去除mime type,atob() 函数用来解码一个已经被base-64编码过的数据 // 需要被频繁处理时,数组好像比字符串性能好点? const text = window.atob(base64Data.split(',')[1]).split('') const buffer = new window.Uint8Array(text.length) for (let i = 0, len = text.length; i < len; i++) { buffer[i] = text[i].charCodeAt(0) } return getBlob([buffer], fileType) } /** * 压缩图片,返回blob对象 * 因为是异步操作,需要通过回调函数的方式取用数据 * @param file */ export function compressImage (file) { return new Promise((resolve, reject) => { const { maxSize } = config const reader = new window.FileReader() const fileSize = file.size / 1024 > 1024 ? (~~(10 * file.size / 1024 / 1024) / 10) + 'MB' : ~~(file.size / 1024) + 'kb' if (mode.mode !== 0) { console.log(`原始Blob对象大小:${fileSize}`) } reader.onload = function () { const result = this.result let img = new window.Image() img.src = result // 若图片小于指定大小,直接返回图片blob if (result.length <= maxSize) { img = null if (mode.mode !== 0) { console.log('图片较小,不需要压缩') } resolve(getBlobFromBase64Data(result, file.type)) return } // 若图片较大,则先压缩再返回压缩后的图片blob if (img.complete) { callback() } else { img.onload = callback } function callback () { const data = compress(img) // resolve(data) img = null resolve(getBlobFromBase64Data(data, file.type)) } } reader.readAsDataURL(file) }) }
调用该方法的示例代码如下,注意这里compressImage方法前面有个await,因为这个方法是异步的,需要用await去等待它返回结果(resolve或reject)。另外需要注意的是data.append是可以接收第三个参数表示文件名的,我不加这个参数的时候接口会报错,看了响应头并上网看了些资料,发现问题应该是由于通过canvas进行图片压缩导致文件名等信息丢失导致发请求时body里少了文件名,写这个接口的后端貌似会通过文件名后缀进行一些判断,所以我如果请求体里少了filename或者filename的值不带有图片名后缀,接口就会报错,需要通过给append方法传入第三个参数来特别指定带有图片名后缀的文件名。
methods: { // 其他方法 async toSetPreview (e, idx) { const { $refs, wait, post, alert } = this const targetElem = e.target || e.srcElement const ext = targetElem.value.split('.').pop().toLowerCase() if (targetElem && targetElem.files && targetElem.files[0]) { if (!/(^jpg$)|(^jpeg$)|(^png$)|(^gif$)/.test(ext)) { alert({ text: '图片后缀名必须为jpg、jpeg、png或gif' }) return } const data = new FormData() wait(true) const fileBlob = await compressImage($refs.itemsWrapper.querySelectorAll('.field-file')[idx].files[0]) data.append('uploadFile', fileBlob, `${+new Date()}.jpeg`) post('/uploadFile', data, { processData: false, contentType: false }).done(data => { if (data.success === true) { this.items[idx].url = data.data.url || '' } else if (data.msg) { alert({ text: data.msg }) } else { alert({ text: '网络异常,请稍后重试' }) } }).fail(() => { alert({ text: '网络异常,请稍后重试' }) }).always(() => { wait(false) }) } else { console.log('您未选择图片') } }, // 其他方法 }
相关的html部分代码如下,这里需要说明的是,如果input标签不加上accept=”image/*”属性的话,会出现在安卓手机微信客户端里只能选择用户手机本地图片不能进行拍照的问题,在iOS系统微信客户端里没有这个问题:
<div v-for="(item, idx) in items" :style="{'background-image': `url(${item.url})`}" @click.prevent="" :class="{'btn-add': !item.url}" class="item"> <input @click.stop="" @change="toSetPreview($event, idx)" type="file" accept="image/*" class="field-file"> <span v-show="item.url" @click="toDeleteImg(idx)" class="btn-delete">删除</span> </div>
Github上有我调研时参考别人的代码写的类似的代码实现demo,具体可以访问https://github.com/Yakima-Teng/iframe-application/tree/master/pages/upload查看。