移动端图片上传的实践

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>