檔案上傳那些事兒

qcloud發表於2017-05-06

 導語

  正好新人導師讓我看看能否把產品目前使用的FileUploader從老的元件庫分離出來的,自己也查閱了相關的各種資料,對檔案上傳的這些事有了更進一步的瞭解。把這些知識點總結一下,供自己日後回顧,也供有需要的同學參考,同時也歡迎各位大牛拍磚指點共同學習。

 FileUpload 物件

  在網頁上傳檔案,最核心元素就是這個HTML DOM的FileUpload物件了。什麼鬼?好像不太熟啊~別急,看到真人就熟了:

<input type="file">

  就是他啊!其實在 HTML 文件中該標籤每出現一次,一個 FileUpload 物件就會被建立。該標籤包含一個按鈕,用來開啟檔案選擇對話方塊,以及一段文字顯示選中的檔名或提示沒有檔案被選中。

  把這個標籤放在<form>標籤內,設定form的action為伺服器目標上傳地址,並點選submit按鈕或通過JS呼叫form的submit()方法就可以實現最簡單的檔案上傳了。

<form id="uploadForm" method="POST" action="upload" enctype="multipart/form-data">
      <input type="file" id="myFile" name="file"></input>
      <input type="submit" value="提交"></input>
 </form>

  這樣就完成功能啦?沒錯。但是你要是敢提交這樣的程式碼,估計臉要被打腫

  都什麼年代了,我們要的是頁面無重新整理上傳!

 更優雅的上傳

  現代網頁通過什麼來實現使用者與伺服器的無重新整理互動?

  ——XMLHttpRequest

  對,就是這個你很熟悉的傢伙。如果你開發的產品支援的瀏覽器是現代瀏覽器,那麼恭喜你,檔案上傳就是這麼easy!特別強調強調現代瀏覽器是因為我們接下來討論的XMLHttpRequest指的是XMLHttpRequest Level 2。

  那什麼是Level 1?為什麼不行?因為它有如下限制:

  • 僅支援文字資料傳輸, 無法傳輸二進位制資料.

  • 傳輸資料時, 沒有進度資訊提示, 只能提示是否完成.

  • 受瀏覽器 同源策略 限制, 只能請求同域資源.

  • 沒有超時機制, 不方便掌控ajax請求節奏.

  而XMLHttpRequest Level 2針對這些缺陷做出了改進:

  • 支援二進位制資料, 可以上傳檔案, 可以使用FormData物件管理表單.

  • 提供進度提示, 可通過 xhr.upload.onprogress 事件回撥方法獲取傳輸進度.

  • 依然受 同源策略 限制, 這個安全機制不會變. XHR2新提供 Access-Control-Allow-Origin 等headers, 設定為 * 時表示允許任何域名請求, 從而實現跨域CORS訪問(有關CORS詳細介紹請耐心往下讀).

  • 可以設定timeout 及 ontimeout, 方便設定超時時長和超時後續處理.

  關於XMLHttpRequest的細節就不在這裡贅述了,有興趣可以移步這篇部落格。目前, 主流瀏覽器基本上都支援XHR2, 除了IE系列需要IE10及更高版本. 因此IE10以下是不支援XHR2的.

  上面提到的FormData就是我們最常用的一種方式。通過在指令碼里新建FormData物件,把File物件設定到表單項中,然後利用XMLHttpRequest非同步上傳到伺服器:

var xhr = new XMLHttpRequest();
var formData = new FormData();
var fileInput = document.getElementById("myFile");
var file = fileInput.files[0];
formdata.append('myFile', file);

xhr.open("POST", "/upload.php");

xhr.onload = function(){
    if(this.status === 200){
        //對請求成功的處理
    }
}

xhr.send(formData);
xhr = null;

  完成最基本的需求無法滿足我們對使用者體驗的追求,所以我們還想要支援上傳進度顯示和上傳圖片預覽。

 上傳進度

  因為是XMLHttpRequest Level 2, 所以很容易就可以支援對上傳進度的監聽。細心地小夥伴會發現在chrome的developer tools的console裡new一個XHR物件,呼叫點運算子就可以看到智慧提示出來一個onprogress事件監聽器,那是不是我們只要繫結XHR物件的progress事件就可以了呢?

  很接近了,但是XHR物件的直屬progress事件並不是用來監聽上傳資源的進度的。XHR物件還有一個屬性upload, 它返回一個XMLHttpRequestUpload 物件,這個物件擁有下列下列方法:

  • onloadstart

  • onprogress

  • onabort

  • onerror

  • onload

  • ontimeout

  • onloadend

  這些方法在XHR物件中都存在同名版本,區別是後者是用於載入資源時,而前者用於資源上傳時。其中onprogress 事件回撥方法可用於跟蹤資源上傳的進度,它的event引數物件包含兩個重要的屬性loaded和total。分別代表當前已上傳的位元組數(number of bytes)和檔案的總位元組數。比如我們可以這樣計算進度百分比:

xhr.upload.onprogress = function(event) {
    if (event.lengthComputable) {
        var percentComplete = (event.loaded / event.total) * 100;
        // 對進度進行處理
    }
}

  其中事件的lengthComputable屬性代表檔案總大小是否可知。如果 lengthComputable 屬性的值是 false,那麼意味著總位元組數是未知並且 total 的值為零。

  如果是現代瀏覽器,可以直接配合HTML5提供的元素使用,方便快捷的顯示進度條。

<progress id="myProgress" value="50" max="100">
</progress>

  其value屬性繫結上面程式碼中的percentComplete的值即可。再進一步我們還可以對<progress>的樣式統一調整,實現優雅降級方案,具體參見這篇文章

  再說說我在測試這個progress事件時遇到的一個問題。一開始我設在onprogress事件回撥裡的斷點總是隻能走到一次,並且loaded值始終等於total。覺得有點詭異,改用console.log列印loaded值不見效,於是直接加大上傳檔案的大小到50MB,終於看到了5個不同的百分比值。

  因為xhr.upload.onprogress在上傳階段(即xhr.send()之後,xhr.readystate=2之前)觸發,每50ms觸發一次。所以檔案太小網路環境好的時候是直接到100%的。

 圖片預覽

  普通青年的圖片預覽方式是待檔案上傳成功後,後臺返回上傳檔案的url,然後把預覽圖片的img元素的src指向該url。這其實達不到預覽的效果和目的。

  屬於文藝青年的現代瀏覽器又登場了:“使用HTML5的FileReader API吧!” 讓我們直接上程式碼,直奔主題:

function handleImageFile(file) {
       var previewArea = document.getElementById('previewArea');
       var img = document.createElement('img');
       var fileInput = document.getElementById("myFile");
       var file = fileInput.files[0];
       img.file = file;
       previewArea.appendChild(img);

       var reader = new FileReader();
       reader.onload = (function(aImg) {
            return function(e) {
                 aImg.src = e.target.result;
            }
       })(img);
       reader.readAsDataURL(file);
}

  這裡我們使用FileReader來處理圖片的非同步載入。在建立新的FileReader物件之後,我們建立了onload函式,然後呼叫readAsDataURL()開始在後臺進行讀取操作。當影象檔案載入後,轉換成一個 data: URL,並傳遞到onload回撥函式中設定給img的src。

  另外我們還可以通過使用物件URL來實現預覽

var img = document.createElement("img");
img.src = window.URL.createObjectURL(file);;
img.onload = function() {
    // 明確地通過呼叫釋放
    window.URL.revokeObjectURL(this.src);
}
previewArea.appendChild(img);

 多檔案支援

  什麼?一個一個新增檔案太煩?別急,開啟一個開關就好了。別忘了我們文章一開頭就登場的FileUpload物件,它有一個multiple屬性。只要這樣

<input id="myFile" type="file" multiple>

  我們就能在開啟的檔案選擇對話方塊中選中多個檔案了。然後你在程式碼裡拿到的FileUpload物件的files屬性就是一個選中的多檔案的陣列了。

var fileInput = document.getElementById("myFile");
var files = fileInput.files;
var formData = new FormData();

for(var i = 0; i < files.length; i++) {
    var file = files[i];
    formData.append('files[]', file, file.name);
}

  FormData的append方法提供第三個可選引數用於指定檔名,這樣就可以使用同一個表單項名,然後用檔名區分上傳的多個檔案。這樣也方便前後臺的迴圈操作。

 二進位制上傳

  有了FileReader,其實我們還有一種上傳的途徑,讀取檔案內容後直接以二進位制格式上傳。

var reader = new FileReader();
reader.onload = function(){
    xhr.sendAsBinary(this.result);
}
// 把從input裡讀取的檔案內容,放到fileReader的result欄位裡
reader.readAsBinaryString(file);

  不過chrome已經把XMLHttpRequest的sendAsBinary方法移除了。所以可能得自行實現一個

XMLHttpRequest.prototype.sendAsBinary = function(text){
    var data = new ArrayBuffer(text.length);
    var ui8a = new Uint8Array(data, 0);
    for (var i = 0; i < text.length; i++){ 
        ui8a[i] = (text.charCodeAt(i) & 0xff);
    }
    this.send(ui8a);
}

  這段程式碼將字串轉成8位無符號整型,然後存放到一個8位無符號整型陣列裡面,再把整個陣列傳送出去。

  到這裡,我們應該可以結合業務需求實現一個比較優雅的檔案上傳元件了。等等,哪裡優雅了?都不支援拖拽!

 拖拽的支援

  利用HTML5的drag & drop事件,我們可以很快實現對拖拽的支援。首先我們可能需要確定一個允許拖放的區域,然後繫結相應的事件進行處理。看程式碼

var dropArea;

dropArea = document.getElementById("dropArea");
dropArea.addEventListener("dragenter", handleDragenter, false);
dropArea.addEventListener("dragover", handleDragover, false);
dropArea.addEventListener("drop", handleDrop, false);

// 阻止dragenter和dragover的預設行為,這樣才能使drop事件被觸發
function handleDragenter(e) {
    e.stopPropagation();
    e.preventDefault();
}

function handleDragover(e) {
    e.stopPropagation();
    e.preventDefault();
}

function handleDrop(e) {
    e.stopPropagation();
    e.preventDefault();

    var dt = e.dataTransfer;
    var files = dt.files;

    // handle files ...
}

  這裡可以把通過事件物件的dataTransfer拿到的files陣列和之前相同處理,以實現預覽上傳等功能。有了這些事件回撥,我們也可以在不同的事件給我們UI元素新增不同的class來實現更好互動效果。

  好了,一個比較優雅的上傳元件可以進入生產模式了。什麼?還要支援IE9?好吧,讓我們來看看IE10以下的瀏覽器如何實現無重新整理上傳。

 借用iframe

  之前說了要實現檔案上傳使用FileUpload物件()即可。這在低版本的IE裡也是適用的。那我們為什麼還要用iframe呢?

  因為在現代瀏覽器中我們可以用XMLHttpRequest Level 2來支援二進位制資料,非同步檔案上傳,並且動態建立FormData。而低版本的IE裡的XMLHttpRequest是Level 1。所以我們通過XHR非同步向伺服器發上傳請求的路走不通了。只能老老實實的用form的submit。

  而form的submit會導致頁面的重新整理。原因分析好了,那麼答案就近在咫尺了。我們能不能讓form的submit不重新整理整個頁面呢?答案就是利用iframe。把form的target指定到一個看不見的iframe,那麼返回的資料就會被這個iframe接受,於是乎就只有這個iframe會重新整理。而它又是看不見的,使用者自然就感知不到了。

window.__iframeCount = 0;
var hiddenframe = document.createElement("iframe");
var frameName = "upload-iframe" + ++window.__iframeCount;
hiddenframe.name = frameName;
hiddenframe.id = frameName;
hiddenframe.setAttribute("style", "width:0;height:0;display:none");
document.body.appendChild(hiddenframe);

var form = document.getElementById("myForm");
form.target = frameName;

  然後響應iframe的onload事件,獲取response

hiddenframe.onload = function(){
    // 獲取iframe的內容,即服務返回的資料
    var resData = this.contentDocument.body.textContent || this.contentWindow.document.body.textContent;
    // 處理資料 。。。

    //刪除iframe
    setTimeout(function(){
        var _frame = document.getElementById(frameName);
        _frame.parentNode.removeChild(_frame);
    }, 100);
}

  iframe的實現大致如此,但是如果檔案上傳的地址與當前頁面不在同一個域下就會出現跨域問題。導致iframe的onload回撥裡的訪問服務返回的資料失敗。

  這時我們再祭出JSONP這把利劍,來解決跨域問題。首先在上傳之前註冊一個全域性的函式,把函式名發給伺服器。伺服器需要配合在response裡讓瀏覽器直接呼叫這個函式。

// 生成全域性函式名,避免衝突
var CALLBACK_NAME = 'CALLBACK_NAME';
var genCallbackName = (function () {
    var i = 0;
    return function () {
        return CALLBACK_NAME + ++i;
    };
})();

var curCallbackName = genCallbackName();
window[curCallbackName] = function(res) {
    // 處理response 。。。

    // 刪除iframe
    var _frame = document.getElementById(frameName);
    _frame.parentNode.removeChild(_frame);
    // 刪除全域性函式本身
    window[curCallbackName] = undefined;
}

// 如果已有其他引數,這裡需要判斷一下,改為拼接 &callback=
form.action = form.action + '?callback=' + curCallbackName;

  好了,實現一個檔案上傳元件的基本知識點大致總結了一下。在這些基礎知識之上我們開始可以為我們的業務開發各種酷炫的File Uploader了。在之後的開發中會把相關的更細的知識點也總結進來,不足之處也歡迎大家指正。

相關文章