TIP
最近在一个项目中需要实现一个移动端上传图片文件的需求,主要需求的是压缩并且按照比例自动裁切图片然后上传。
一听是蛮简单的,因为是在移动端使用,所以完全可以使用 HTML5 的新特性以及一些 API。
主要的思路是这样:
- 监听一个 input (type='file') 的 change 事件,然后拿到文件的 file ;
- 把 file 转成 dataURL ;
- 然后用 canvas 绘制图片,绘制的时候经过算法按比例裁剪;
- 然后再把 canvas 转成 dataURL ;
- 再把 dataURL 转成 blob ;
- 接着把 blob append 到 FormData 的实例对象。
- 最后上传。
主要用到的 FileReader 、 canvas 、 FormData 、 Blob 这几个 API。
开发过程遇到了蛮多坑,特别是在android下的微信浏览器内。
监听 input(type=file) 获取文件内容。
// html 片段 <input type="file" id="file-input" name="image" accept="image/gif, image/jpeg, image/png">
对于 type 为 file 的 input 我们可以设置 accept 属性来现在我们要上传的文件类型,这里的目的是上传图片文件,所以我们可以设置: accept="image/gif, image/jpeg, image/png" 。
// JavaScript document.getElementById('file-input').onchange= function (event) { // 通过 event.target 回去 input 元素对象,然后拿到 files list,取第一个 file let file = event.target.files[0]; // compressImage 在下面解释,它接受三个参数,文件、裁剪的长宽比例,回调函数(回调函数获得一个 FormData 对象,文件已经存在里面了); compressImage(file, [1, 1], (targetFormData) => { //...... 这里获取到了 targetFormData,就可以直接使用它上传了 }); };
fileToDataURL: file 转成 dataURL
这里用到的是 FileReader 这个 API。
https://developer.mozilla.org/en-US/docs/Web/API/FileReader
/** * file 转成 dataURL * @param file 文件 * @param callback 回调函数 */ function fileToDataURL (file, callback) { const reader = new window.FileReader(); reader.onload = function (e) { callback(e.target.result); }; reader.readAsDataURL(file); }
compressDataURL:dataURL 图片绘制 canvas,然后经过处理(裁剪 & 压缩)再转成 dataURL
一开始是这样的
- 我们需要创建一个 Image 对象,然后把 src 设置成 dataURL ,获取到这张图片;
- 我们需要创建一个 canvas 元素,用来处理绘制图片;
- 获取裁剪的长宽比例,然后判断图片的实际长宽比例,按照最大化偏小的长或宽然后另一边采取中间部分,和 css 把 background 设置 center / cover 一个道理;
- 调用 ctx.drawImage 绘制图片;
- 使用 canvas.toDataURL 把 canvans 转成 dataURL 。
/** * 使用 canvas 压缩处理 dataURL * @param dataURL * @param ratio 比例 * @param callback */ function compressDataURL (dataURL, ratio, callback) { // 1 const img = new window.Image(); img.src = dataURL; // 2 const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // 3 canvas.width = 100 * ratio[0]; canvas.height = 100 * ratio[2]; const RATIO = canvas.width / canvas.height; let cutx = 0; let cuty = 0; let cutw = img.width; let cuth = img.height; if (cutw / cuth > RATIO) { // 宽超过比例了]] let realw = cuth * RATIO; cutx = (cutw - realw) / 2; cutw = realw; } else if (cutw / cuth < RATIO) { // 长超过比例了]] let realh = cutw / RATIO; cuty = (cuth - realh) / 2; cuth = realh; } // 4 ctx.drawImage(img, cutx, cuty, cutw, cuth, 0, 0, canvas.width, canvas.height); const ndata = canvas.toDataURL('image/jpeg', 1); callback(ndata); }
一切的运行在pc端的chrome浏览器下模拟都很好,但是在移动端测试的时候发现 canvas 无法绘制出图片,发现是 img 设置 src 有延迟,导致还没获取到图片图像就开始绘制。
改进:监听 img.onload 事件来处理之后的操作:
/** * 使用 canvas 压缩 dataURL * @param dataURL * @param ratio * @param callback */ function compressDataURL (dataURL, ratio, callback) { const img = new window.Image(); img.src = dataURL; // onload img.onload = function () { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = 100 * ratio.width; canvas.height = 100 * ratio.height; const RATIO = canvas.width / canvas.height; let cutx = 0; let cuty = 0; let cutw = img.width; let cuth = img.height; if (cutw / cuth > RATIO) { // 宽超过比例了]] let realw = cuth * RATIO; cutx = (cutw - realw) / 2; cutw = realw; } else if (cutw / cuth < RATIO) { // 长超过比例了]] let realh = cutw / RATIO; cuty = (cuth - realh) / 2; cuth = realh; } ctx.drawImage(img, cutx, cuty, cutw, cuth, 0, 0, canvas.width, canvas.height); const ndata = canvas.toDataURL('image/jpeg', 1); callback(ndata); }; }
dataURLtoBlob:dataURL 转成 Blob
这一步我们把 dataURL 转成 Blob
/** * dataURL 转成 blob * @param dataURL * @return blob */ function dataURLtoBlob (dataURL) { let binaryString = atob(dataURL.split(',')[1]); let arrayBuffer = new ArrayBuffer(binaryString.length); let intArray = new Uint8Array(arrayBuffer); let mime = dataURL.split(',')[0].match(/:(.*?);/)[1] for (let i = 0, j = binaryString.length; i < j; i++) { intArray[i] = binaryString.charCodeAt(i); } let data = [intArray]; let result = new Blob(data, { type: mime }); return result; }
很完美了吗,在pc端模拟成功,在移动端chrome浏览器测试成功,但是在微信浏览器中失败,经过 try...catch 发现是在 new Blob 的时候失败。
查看之后发现是这个 API 对 Android 的支持还不明。
解决方法是利用 BlobBuilder 这个老 API 来解决: https://developer.mozilla.org/en-US/docs/Web/API/BlobBuilder
因为这个 API 已经被遗弃,不同机型和安卓版本兼容性不一致,所以需要一个判断。
解决方法:
/** * dataURL 转成 blob * @param dataURL * @return blob */ function dataURLtoBlob (dataURL) { let binaryString = atob(dataURL.split(',')[1]); let arrayBuffer = new ArrayBuffer(binaryString.length); let intArray = new Uint8Array(arrayBuffer); let mime = dataURL.split(',')[0].match(/:(.*?);/)[1] for (let i = 0, j = binaryString.length; i < j; i++) { intArray[i] = binaryString.charCodeAt(i); } let data = [intArray]; let result; try { result = new Blob(data, { type: mime }); } catch (error) { window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder; if (error.name === 'TypeError' && window.BlobBuilder){ var builder = new BlobBuilder(); builder.append(arrayBuffer); result = builder.getBlob(type); } else { throw new Error('没救了'); } } return result; }
把获取到的 blob append 到 FormData 实例,执行回调
这一步使用到我们之前的东西。
/** * 压缩图片 * @param file 图片文件 * @param ratio 比例 * @param callback 回调,得到一个 包含文件的 FormData 实例 */ function compressImage (file, ratio, callback) { fileToDataURL(file, (dataURL) => { compressDataURL(dataURL, ratio, (newDataURL) => { const newBlob = dataURLtoBlob(newDataURL); const oData = new FormData(); oData.append('file', blob); callback(oData); }); }); }
回到第一步,上传文件
// JavaScript document.getElementById('file-input').onchange= function (event) { // 通过 event.target 回去 input 元素对象,然后拿到 files list,取第一个 file let file = event.target.files[0]; // 接受三个参数,文件、裁剪的长宽比例,回调函数(回调函数获得一个 FormData 对象,文件已经存在里面了); compressImage(file, [1, 1], (targetFormData) => { let xhr = new XMLHttpRequest(); // 进度监听 // xhr.upload.addEventListener('progress', progFoo, false); // 加载监听 // xhr.addEventListener('load', loadFoo, false); // 错误监听 // xhr.addEventListener('error', errorFoo, false); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status === 200) { // 上传成功,获取到结果 results let results = JSON.parse(xhr.responseText); // ...... } } else { // 上传失败 } } }; xhr.open('POST', '/api/upload', true); xhr.send(targetFormData); }); };
一切似乎都很完美,pc 端模拟测试通过,但是到移动端却发现上传了一个空文件,这不科学!!!查文档后发现这么一句话:
Note: XHR in Android 4.0 sends empty content for FormData with blob.
简直蒙蔽。
在 上找到了解决方案: http://stackoverflow.com/questions/15639070/empty-files-uploaded-in-android-native-browser/28809955#28809955
通过自己包装 FormDataShim 和重写 XMLHttpRequest.prototype.send 函数:
// Android上的AppleWebKit 534以前的内核存在一个Bug, // 导致FormData加入一个Blob对象后,上传的文件是0字节 // QQ X5浏览器也有这个BUG var needsFormDataShim = (function () { var bCheck = ~navigator.userAgent.indexOf('Android') && ~navigator.vendor.indexOf('Google') && !~navigator.userAgent.indexOf('Chrome'); return bCheck && navigator.userAgent.match(/AppleWebKit//(/d+)/).pop() <= 534 || /MQQBrowser/g.test(navigator.userAgent); })(); // 重写 Blob 构造函数,在 XMLHttpRequest.prototype.send 中会使用到 var BlobConstructor = ((function () { try { new Blob(); return true; } catch (e) { return false; } })()) ? window.Blob : function (parts, opts) { let bb = new ( window.BlobBuilder || window.WebKitBlobBuilder || window.MSBlobBuilder || window.MozBlobBuilder ); parts.forEach(function (p) { bb.append(p); }); return bb.getBlob(opts ? opts.type : undefined); }; // 手动包装 FormData 同时重写 XMLHttpRequest.prototype.send var FormDataShim = (function () { var formDataShimNums = 0; return function FormDataShim () { var o = this; // Data to be sent let parts = []; // Boundary parameter for separating the multipart values let boundary = Array(21).join('-') + (+new Date() * (1e16 * Math.random())).toString(36); // Store the current XHR send method so we can safely override it let oldSend = XMLHttpRequest.prototype.send; this.getParts = function () { return parts.toString(); }; this.append = function (name, value, filename) { parts.push('--' + boundary + '/r/nContent-Disposition: form-data; name="' + name + '"'); if (value instanceof Blob) { parts.push('; filename="' + (filename || 'blob') + '"/r/nContent-Type: ' + value.type + '/r/n/r/n'); parts.push(value); } else { parts.push('/r/n/r/n' + value); } parts.push('/r/n'); }; formDataShimNums++; XMLHttpRequest.prototype.send = function (val) { let fr; let data; let oXHR = this; if (val === o) { // Append the final boundary string parts.push('--' + boundary + '--/r/n'); // Create the blob data = new BlobConstructor(parts); // Set up and read the blob into an array to be sent fr = new FileReader(); fr.onload = function () { oldSend.call(oXHR, fr.result); }; fr.onerror = function (err) { throw err; }; fr.readAsArrayBuffer(data); // Set the multipart content type and boudary this.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + boundary); formDataShimNums--; if (formDataShimNums === 0) { XMLHttpRequest.prototype.send = oldSend; } } else { oldSend.call(this, val); } }; }; })();
SUCCESS
重写 compressImage
/** * 压缩图片 * @param file 图片文件 * @param ratio 比例 * @param callback 回调,得到一个 包含文件的 FormData 实例 */ function compressImage (file, ratio, callback) { fileToDataURL(file, (dataURL) => { compressDataURL(dataURL, ratio, (newDataURL) => { const newBlob = dataURLtoBlob(newDataURL); // 判断是否需要我们之前的重写 let NFormData = needsFormDataShim() ? FormDataShim : window.FormData; const oData = new NFormData(); oData.append('file', blob); callback(oData); }); }); }
到这一步总算成功。
参考:
http://www.alloyteam.com/2015/04/ru-he-zai-yi-dong-web-shang-shang-chuan-wen-jian/
来自: http://qiutc.me/post/uploading-image-file-in-mobile-fe.html