移动端图片上传的实践
PBWLei
8年前
<h2>TIP</h2> <p>最近在一个项目中需要实现一个移动端上传图片文件的需求,主要需求的是压缩并且按照比例自动裁切图片然后上传。</p> <p>一听是蛮简单的,因为是在移动端使用,所以完全可以使用 HTML5 的新特性以及一些 API。</p> <p>主要的思路是这样:</p> <ol> <li>监听一个 input (type='file') 的 change 事件,然后拿到文件的 file ;</li> <li>把 file 转成 dataURL ;</li> <li>然后用 canvas 绘制图片,绘制的时候经过算法按比例裁剪;</li> <li>然后再把 canvas 转成 dataURL ;</li> <li>再把 dataURL 转成 blob ;</li> <li>接着把 blob append 到 FormData 的实例对象。</li> <li>最后上传。</li> </ol> <p>主要用到的 <a href="/misc/goto?guid=4959674108627032153" rel="nofollow,noindex"> FileReader </a> 、 <a href="/misc/goto?guid=4959674108711330574" rel="nofollow,noindex"> canvas </a> 、 <a href="/misc/goto?guid=4959674108791154019" rel="nofollow,noindex"> FormData </a> 、 <a href="/misc/goto?guid=4959674108874919885" rel="nofollow,noindex"> Blob </a> 这几个 API。</p> <p>开发过程遇到了蛮多坑,特别是在android下的微信浏览器内。</p> <h2>监听 input(type=file) 获取文件内容。</h2> <pre> // html 片段 <input type="file" id="file-input" name="image" accept="image/gif, image/jpeg, image/png"> </pre> <p>对于 type 为 file 的 input 我们可以设置 accept 属性来现在我们要上传的文件类型,这里的目的是上传图片文件,所以我们可以设置: accept="image/gif, image/jpeg, image/png" 。</p> <pre> // 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,就可以直接使用它上传了 }); }; </pre> <h2>fileToDataURL: file 转成 dataURL</h2> <p>这里用到的是 FileReader 这个 API。</p> <p><a href="/misc/goto?guid=4959674108627032153" rel="nofollow,noindex">https://developer.mozilla.org/en-US/docs/Web/API/FileReader</a></p> <pre> /** * 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); } </pre> <h2>compressDataURL:dataURL 图片绘制 canvas,然后经过处理(裁剪 & 压缩)再转成 dataURL</h2> <p>一开始是这样的</p> <ol> <li>我们需要创建一个 Image 对象,然后把 src 设置成 dataURL ,获取到这张图片;</li> <li>我们需要创建一个 canvas 元素,用来处理绘制图片;</li> <li>获取裁剪的长宽比例,然后判断图片的实际长宽比例,按照最大化偏小的长或宽然后另一边采取中间部分,和 css 把 background 设置 center / cover 一个道理;</li> <li>调用 ctx.drawImage 绘制图片;</li> <li>使用 canvas.toDataURL 把 canvans 转成 dataURL 。</li> </ol> <pre> /** * 使用 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); } </pre> <p>一切的运行在pc端的chrome浏览器下模拟都很好,但是在移动端测试的时候发现 canvas 无法绘制出图片,发现是 img 设置 src 有延迟,导致还没获取到图片图像就开始绘制。</p> <p>改进:监听 img.onload 事件来处理之后的操作:</p> <pre> /** * 使用 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); }; } </pre> <h2>dataURLtoBlob:dataURL 转成 Blob</h2> <p>这一步我们把 dataURL 转成 Blob</p> <pre> /** * 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; } </pre> <p>很完美了吗,在pc端模拟成功,在移动端chrome浏览器测试成功,但是在微信浏览器中失败,经过 try...catch 发现是在 new Blob 的时候失败。</p> <p>查看之后发现是这个 API 对 Android 的支持还不明。</p> <p>解决方法是利用 BlobBuilder 这个老 API 来解决: <a href="/misc/goto?guid=4959674108956323444" rel="nofollow,noindex">https://developer.mozilla.org/en-US/docs/Web/API/BlobBuilder</a></p> <p>因为这个 API 已经被遗弃,不同机型和安卓版本兼容性不一致,所以需要一个判断。</p> <p>解决方法:</p> <pre> /** * 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; } </pre> <h2>把获取到的 blob append 到 FormData 实例,执行回调</h2> <p>这一步使用到我们之前的东西。</p> <pre> /** * 压缩图片 * @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); }); }); } </pre> <h2>回到第一步,上传文件</h2> <pre> // 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); }); }; </pre> <p>一切似乎都很完美,pc 端模拟测试通过,但是到移动端却发现上传了一个空文件,这不科学!!!查文档后发现这么一句话:</p> <p>Note: XHR in Android 4.0 sends empty content for FormData with blob.</p> <p>简直蒙蔽。</p> <p>在 上找到了解决方案: <a href="/misc/goto?guid=4959674109045387747" rel="nofollow,noindex">http://stackoverflow.com/questions/15639070/empty-files-uploaded-in-android-native-browser/28809955#28809955</a></p> <p>通过自己包装 FormDataShim 和重写 XMLHttpRequest.prototype.send 函数:</p> <pre> // 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); } }; }; })(); </pre> <h2>SUCCESS</h2> <p>重写 compressImage</p> <pre> /** * 压缩图片 * @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); }); }); } </pre> <p>到这一步总算成功。</p> <p>参考:</p> <p><a href="/misc/goto?guid=4959674109045387747" rel="nofollow,noindex">http://stackoverflow.com/questions/15639070/empty-files-uploaded-in-android-native-browser/28809955#28809955</a></p> <p><a href="/misc/goto?guid=4959674109127372756" rel="nofollow,noindex">http://www.alloyteam.com/2015/04/ru-he-zai-yi-dong-web-shang-shang-chuan-wen-jian/</a></p> <p> </p> <p>来自: <a href="/misc/goto?guid=4959674109209319867" rel="nofollow">http://qiutc.me/post/uploading-image-file-in-mobile-fe.html</a></p> <p> </p>