移動端圖片上傳的實踐
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"> </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
一開始是這樣的
- 我們需要創建一個 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);
}
</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
/**
- 壓縮圖片
- @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>
到這一步總算成功。
參考:
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