移動端圖片上傳的實踐

PBWLei 8年前發布 | 33K 次閱讀 Android 移動開發 canvas

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"> </pre>

對于 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); } </pre>

    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); } </pre>

    一切的運行在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); }; } </pre>

    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; } </pre>

    很完美了嗎,在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; } </pre>

    把獲取到的 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); }); }); } </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>

    一切似乎都很完美,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);
  }
};

}; })(); </pre>

SUCCESS

重寫 compressImage

/**

 本文由用戶 PBWLei 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!