關於 HTML5 的檔案上傳處理,相容,以及 BLOB 物件的使用
目前 HTML5 已經逐漸普及併成為主流,與之相關的 Single Page App 技術也逐漸被廣泛應用起來,加上 Canvas 等等新的工具的支援,在前端可以做的事情可謂是非常多。
但是,不得不否認,各種原生的 HTML5 工具支援相容性還並不是太好,本文的緣起就是基於在微信瀏覽器(QQ瀏覽器X5核心)下面開發表單提交上傳附件的環節,出現了相容性的問題(具體情況就是往 FormData 物件中置入 Blob 物件的時候產生 bug,提交資料為空。)
因此,本人基於 jQuery,深入底層研究了 jQuery AJAX 對請求的封裝,以及在客戶端的一些二進位制流的處理,頗有心得,需要總結下來。
研究過程中關於本主體的相關參考
關於 FormData
FormData 是一個 HTML5 的原生物件,使用 FormData 可以將一個 Form 或者一系列的欄位包裝成一個物件,然後通過 jQuery 或者標準的 XHR 進行 Ajax 傳送。
下面是一個簡單的例子:
1. 直接封裝整個 form:
var formElement = document.querySelector("form");
var request = new XMLHttpRequest();
request.open("POST", "submitform.php");
request.send(new FormData(formElement));
2. 逐個欄位產生:
var formData = new FormData();
formData.append("username", "Groucho");
formData.append("accountnum", 123456); // number 123456 is immediately converted to a string "123456"
// HTML file input, chosen by user
formData.append("userfile", fileInputElement.files[0]);
// JavaScript file-like object
var content = '<a id="a"><b id="b">hey!</b></a>'; // the body of the new file...
var blob = new Blob([content], { type: "text/xml"});
formData.append("webmasterfile", blob);
var request = new XMLHttpRequest();
request.open("POST", "http://foo.com/submitform.php");
request.send(formData);
如果使用 jQuery,產生含有資料的 FormData 物件之後,我們可以將其傳進 $.ajax
的
data 引數裡面。
$.ajax({
url: 'http://example.com/api',
method: 'post',
processData: false
contentType: false,
data: formdata
});
這種情況,只需要將 formdata 物件傳入,並且制定 processData 和 contentType 為 false,就可以用 multipart/form-data
的方式通過
ajax post 一個請求出去,當然這裡面就可以包含一般的二進位制物件(File 或者 Blob)
但是,實際測試中發現騰訊QQ瀏覽器在將 Blob 傳入 Formdata 中的時候就會出問題,肯定是核心對 FormData 的實現上面有 Bug。
於是為了相容這個問題,我試圖自己封裝一個模擬出來的表單,即由 boundary 分割的multipart/form-data
請求體。
請求體的封裝
再重複一下,如果使用 multipart/form-data
的
Content-Type 去提交一個請求,實際上發出的 HTTP 請求是這樣的:
請求頭:
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryWwE7y8P3JK82rxsk
請求體:
------WebKitFormBoundaryWwE7y8P3JK82rxsk
Content-Disposition: form-data; name="username"
admin
------WebKitFormBoundaryWwE7y8P3JK82rxsk
Content-Disposition: form-data; name="avatar"; filename="avatar.png"
Content-Type: image/png
[binary stream]
------WebKitFormBoundaryWwE7y8P3JK82rxsk--
關鍵的格式就是這樣,只要滿足這個規範,後臺就可以從(例如php) $_POST
和 $_FILE
獲取提交的欄位或者上傳的檔案。
因此,只要我們能夠將這樣的請求頭和請求體按格式生成出來,就可以為所欲為了。
至於我們的呼叫方式,就是通過 $.ajax
的介面來給出。
其中,請求頭很簡單,首先隨機一個 boundary 字串,然後通過 ajax 的 contentType 引數輸入即可:
var makeBoundary = function() {
return '----JQBoundary'+btoa(Math.random().toString()).substr(0,12);
};
var boundary = makeBoundary();
$.ajax({
contentType: 'multipart/form-data; boundary='+boundary,
// ...
});
如此即可在 ajax 請求中指定請求頭。
難點在於請求體 ,請求體是在 $.ajax
方法的
data 部分給出的。
一般初學者來說,傳進去的 data 是一個字典,還有我們剛剛上面提到的,給一個 FormData 物件也是可以的。
然後有一個關鍵點,processData 引數預設是 true,這時候 jQuery 在 ajax 之前會將我們傳進去的字典序列化之後,放在 url 中(get 方式)或者放在 payload 請求體裡面。
那麼如果我們傳進去 FormData 或者後面要講的,傳進去一個二進位制流,就需要將這個 processData 設定為 false 了。
那麼問題來了,我們如果要傳進去一個二進位制流,應該怎麼整?
首先,我們並不知道,data 這個引數除了會吃字典和 FormData 還會吃些什麼,我們先假設它會吃普通的 string。
所以我們試一下先不涉及二進位制內容,將一個含有 unicode 內容的字串傳進去,看看能行不能行:
$.ajax(url, {
method: 'post',
processData: false,
contentType: 'multipart/form-data; boundary='+boundary,
data: '--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="username"\r\n\r\n' +
'呆滯的慢板\r\n' +
'--' + boundary + '--\r\n'
});
上面這段是行得通的,因為是我從後臺讀取請求體之後一比一仿造出來的,肯定可以騙過後臺。
只是我們要知道,前端還默默為我們做了一件事,就是將中文自動執行了編碼,因為從前臺看,’呆滯的慢板’在字串中的長度是 5,但是在後臺看,這五個字被編製成了 15 位的 utf 編碼二進位制串。
ok,一種方法行得通,那麼如果涉及二進位制內容呢(例如圖片)。
獲取和處理二進位制流
首先,我們需要讀取二進位制流的內容。
對於二進位制流,我們可以這樣獲取:
var file = document.getElementById('fileElement').files[0];
var reader = new FileReader();
reader.onload = function(e) {
var content = e.target.result;
console.log(content);
}
reader.readAsBinaryString();
這樣得出來的 binaryString 格式,是一個位元組流,與 atob (相當於base64decode) 一個 base64 串得到的輸出是同樣的格式。
同樣,還有 readAsText,readAsDataUrl, readAsArrayBuffer 等方法,但是獲取出來的e.target.result
是不一樣的。
可以看到,如果這樣,就可以非同步獲取檔案的二進位制內容,作為一個字串,然後我們加上 boundary 拼接到其他欄位的整體 formdata 中,然後就可以最終串接成一個完整的 payload 了。
然後我們將這樣的 data ajax 出去,發現死翹翹了。
另一種可接受的 data 流格式:ArrayBuffer
失敗的原因是,由於文字型別(而且還是 unicode 文字)型別與直接的二進位制流放在一起,產生了編碼混亂,ajax 發出之間,由於這是一個字串,因此 xhr 物件幫我們自動編碼這個字串,結果造成了二進位制流的破壞,後臺識別不出來了。
換個說法,我們遍歷這個字串,碰到一些中文的 unicode 字元,他的取值是超出一個位元組的,因此作為流編碼,應該按照 utf8 方式,編碼成三個位元組才對。
那怎麼辦?只有我們自己來做了。
經過了無盡的折騰撞牆試錯,直接寫出寶貴的結論:
可以通過 unicode 和二進位制混編構造的字串,在傳遞給 ajax 之前,將其一個一個位元組編碼到 Uint8Array 中,再獲取其 buffer,作為 data 傳給 ajax。
下面一步一步來:
首先,我們要將中間所有涉及的 unicode 字元一個一個拆開:
關於這個問題,我在另一篇文章已經寫得很詳細了:
於是我們可以得到一個確保每一個值都不會超過一個位元組的字串。
/**
* Encode a given string to utf8 encoded binary string.
* @param str:
* @returns string:
*/
var str2utf8 = window.TextEncoder ? function(str) {
var encoder = new TextEncoder('utf8');
var bytes = encoder.encode(str);
var result = '';
for(var i = 0; i < bytes.length; ++i) {
result += String.fromCharCode(bytes[i]);
}
return result;
} : function(str) {
return eval('\''+encodeURI(str).replace(/%/gm, '\\x')+'\'');
};
然後我們將其編碼成 Uint8Array,過程省略,最終就是這個函式:
var str2Uint8Array = function(str) {
var arr = [], c;
for(var i = 0; i < str.length; ++i) {
c = str.charCodeAt(i);
if(c > 0xff) {
alert('Char code range out of 8 bit, parse error!');
return [];
}
arr.push(str.charCodeAt(i));
}
return new Uint8Array(arr);
};
那麼最終我們可以這樣來發送一個 ajax,就可以完全相容二進位制流和普通欄位了:
var strctured_body = '...'; // 這是我們手工混編出來的,帶有 unicode 字元的完整 request body
var encoded_body = str2utf8(structured_body);
var byte_array = str2Uint8Array(encoded_body);
$.ajax(url, {
method: 'post',
processData: false,
contentType: 'multipart/form-data; boundary='+boundary,
data: byte_array.buffer
});
試了無數種方法,最後只有這樣能夠將自己編制的內容完整 post 出去,使用 ArrayBuffer 的格式。
後記
最終,我還是耐不住寂寞,做了一個外掛,自動做好這些封裝,當然,中間還涉及到了介面的設計,如果再做此類工作,參考我的這個外掛就可以了。
物件的使用