1. 程式人生 > >關於 HTML5 的檔案上傳處理,相容,以及 BLOB 物件的使用

關於 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 的格式。

後記

最終,我還是耐不住寂寞,做了一個外掛,自動做好這些封裝,當然,中間還涉及到了介面的設計,如果再做此類工作,參考我的這個外掛就可以了。

物件的使用