原创

移动端图片上传的实践

TIP

最近在一个项目中需要实现一个移动端上传图片文件的需求,主要需求的是压缩并且按照比例自动裁切图片然后上传。

一听是蛮简单的,因为是在移动端使用,所以完全可以使用 HTML5 的新特性以及一些 API。

主要的思路是这样:

  1. 监听一个 input (type='file') 的 change 事件,然后拿到文件的 file ;
  2. 把 file 转成 dataURL ;
  3. 然后用 canvas 绘制图片,绘制的时候经过算法按比例裁剪;
  4. 然后再把 canvas 转成 dataURL ;
  5. 再把 dataURL 转成 blob ;
  6. 接着把 blob append 到 FormData 的实例对象。
  7. 最后上传。

主要用到的 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

一开始是这样的

  1. 我们需要创建一个 Image 对象,然后把 src 设置成 dataURL ,获取到这张图片;
  2. 我们需要创建一个 canvas 元素,用来处理绘制图片;
  3. 获取裁剪的长宽比例,然后判断图片的实际长宽比例,按照最大化偏小的长或宽然后另一边采取中间部分,和 css 把 background 设置 center / cover 一个道理;
  4. 调用 ctx.drawImage 绘制图片;
  5. 使用 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://stackoverflow.com/questions/15639070/empty-files-uploaded-in-android-native-browser/28809955#28809955

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

 

正文到此结束
Loading...