如何使用 Vanilla JavaScript 製作拖放文件上傳器
已發表: 2022-03-10眾所周知,文件選擇輸入很難按照開發人員想要的方式設置樣式,因此許多人只是將其隱藏並創建一個打開文件選擇對話框的按鈕。 不過,如今,我們有一種更奇特的方式來處理文件選擇:拖放。
從技術上講,這已經成為可能,因為文件選擇輸入的大多數(如果不是全部)實現允許您將文件拖到其上以選擇它們,但這需要您實際顯示file
元素。 那麼,讓我們實際使用瀏覽器給我們的API來實現一個拖放文件選擇器和上傳器。
在本文中,我們將使用“vanilla”ES2015+ JavaScript(無框架或庫)來完成這個項目,並且假設您具備瀏覽器中 JavaScript 的應用知識。 這個例子——除了 ES2015+ 語法,它可以很容易地更改為 ES5 語法或由 Babel 轉譯——應該與每個常青瀏覽器以及 IE 10 和 11 兼容。
以下是您將要製作的內容的快速瀏覽:
拖放事件
我們需要討論的第一件事是與拖放相關的事件,因為它們是此功能背後的驅動力。 總之,瀏覽器會觸發八個與拖放相關的事件: drag
、 dragend
、 dragenter
、 dragexit
、 dragleave
、 dragover
、 dragstart
和drop
。 我們不會遍歷所有這些,因為drag
、 dragend
、 dragexit
和dragstart
都在被拖動的元素上觸發,在我們的例子中,我們將從文件系統中拖動文件而不是 DOM 元素,所以這些事件永遠不會彈出。
如果你對它們感到好奇,你可以閱讀 MDN 上關於這些事件的一些文檔。
如您所料,您可以像為大多數瀏覽器事件註冊事件處理程序一樣為這些事件註冊事件處理程序:通過addEventListener
。
let dropArea = document.getElementById('drop-area') dropArea.addEventListener('dragenter', handlerFunction, false) dropArea.addEventListener('dragleave', handlerFunction, false) dropArea.addEventListener('dragover', handlerFunction, false) dropArea.addEventListener('drop', handlerFunction, false)
這是一個小表格,描述了這些事件的作用,使用代碼示例中的dropArea
以使語言更清晰:
事件 | 什麼時候被解僱? |
---|---|
dragenter | 被拖動的項目被拖動到 dropArea 上,如果用戶將其放在那裡,則使其成為放置事件的目標。 |
dragleave | 被拖動的項目被拖出 dropArea 並拖到另一個元素上,使其成為放置事件的目標。 |
dragover | 每隔幾百毫秒,當拖動的項目在 dropArea 上方並正在移動時。 |
drop | 用戶鬆開鼠標按鈕,將拖動的項目放到 dropArea 上。 |
請注意,拖動的項目被拖動到dropArea
的子元素上, dragleave
將在dropArea
上觸發,而dragenter
將在該子元素上觸發,因為它是新target
。 drop
事件將向上傳播到dropArea
(除非傳播在到達那里之前被不同的事件偵聽器停止),因此儘管它不是事件的target
,它仍會在dropArea
上觸發。
另請注意,為了創建自定義拖放交互,您需要在每個偵聽器中為這些事件調用event.preventDefault()
。 如果您不這樣做,瀏覽器將最終打開您放置的文件,而不是將其發送到drop
事件處理程序。
設置我們的表格
在我們開始添加拖放功能之前,我們需要一個帶有標准file
輸入的基本表單。 從技術上講,這不是必需的,但如果用戶的瀏覽器不支持拖放 API,最好提供它作為替代方案。
<div> <form class="my-form"> <p>Upload multiple files with the file dialog or by dragging and dropping images onto the dashed region</p> <input type="file" multiple accept="image/*" onchange="handleFiles(this.files)"> <label class="button" for="fileElem">Select some files</label> </form> </div>
很簡單的結構。 您可能會注意到input
上有一個onchange
處理程序。 我們稍後再看。 在form
中添加一個action
和一個submit
按鈕來幫助那些沒有啟用 JavaScript 的人也是一個好主意。 然後,您可以使用 JavaScript 擺脫它們以獲得更清晰的表單。 在任何情況下,您都需要一個服務器端腳本來接受上傳,無論它是內部開發的,還是您使用 Cloudinary 之類的服務來為您完成。 除了這些註釋之外,這裡沒有什麼特別的,所以讓我們添加一些樣式:
#drop-area { border: 2px dashed #ccc; border-radius: 20px; width: 480px; font-family: sans-serif; margin: 100px auto; padding: 20px; } #drop-area.highlight { border-color: purple; } p { margin-top: 0; } .my-form { margin-bottom: 10px; } #gallery { margin-top: 10px; } #gallery img { width: 150px; margin-bottom: 10px; margin-right: 10px; vertical-align: middle; } .button { display: inline-block; padding: 10px; background: #ccc; cursor: pointer; border-radius: 5px; border: 1px solid #ccc; } .button:hover { background: #ddd; } #fileElem { display: none; }
許多這些風格還沒有發揮作用,但沒關係。 現在的亮點是file
輸入是隱藏的,但是它的label
被設計成一個按鈕,所以人們會意識到他們可以點擊它來調出文件選擇對話框。 我們還遵循慣例,用虛線勾勒出放置區域。
添加拖放功能
現在我們進入情況的實質:拖放。 讓我們在頁面底部或單獨的文件中添加一個腳本,但是您喜歡這樣做。 我們在腳本中需要的第一件事是對放置區域的引用,以便我們可以將一些事件附加到它:
let dropArea = document.getElementById('drop-area')
現在讓我們添加一些事件。 我們將從向所有事件添加處理程序開始,以防止默認行為並阻止事件冒泡超過必要的水平:
;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, preventDefaults, false) }) function preventDefaults (e) { e.preventDefault() e.stopPropagation() }
現在讓我們添加一個指示器,通過使用 CSS 更改放置區域的邊框顏色的顏色,讓用戶知道他們確實將項目拖到了正確的區域。 樣式應該已經在#drop-area.highlight
選擇器下,所以讓我們在必要時使用 JS 添加和刪除該highlight
類。
;['dragenter', 'dragover'].forEach(eventName => { dropArea.addEventListener(eventName, highlight, false) }) ;['dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, unhighlight, false) }) function highlight(e) { dropArea.classList.add('highlight') } function unhighlight(e) { dropArea.classList.remove('highlight') }
由於我之前提到的,我們不得不同時使用dragenter
和dragover
來突出顯示。 如果您開始直接將鼠標懸停在dropArea
,然後將鼠標懸停在其中一個子級上,則將dragleave
並刪除突出顯示。 dragover
事件在dragenter
和dragleave
事件之後觸發,因此在我們看到它被移除之前,高亮將被添加回dropArea
。
當拖動的項目離開指定區域或放下項目時,我們也會刪除突出顯示。
現在我們需要做的就是弄清楚當一些文件被刪除時該怎麼做:
dropArea.addEventListener('drop', handleDrop, false) function handleDrop(e) { let dt = e.dataTransfer let files = dt.files handleFiles(files) }
這並沒有使我們接近完成,但它做了兩件重要的事情:
- 演示如何獲取已刪除文件的數據。
- 使用其
onchange
處理程序將我們帶到file
input
所在的同一位置:等待handleFiles
。
請記住, files
不是數組,而是FileList
。 因此,當我們實現handleFiles
時,我們需要將其轉換為數組以便更輕鬆地對其進行迭代:
function handleFiles(files) { ([...files]).forEach(uploadFile) }
那是虎頭蛇尾的。 讓我們進入uploadFile
來了解真正的內容。
function uploadFile(file) { let url = 'YOUR URL HERE' let formData = new FormData() formData.append('file', file) fetch(url, { method: 'POST', body: formData }) .then(() => { /* Done. Inform the user */ }) .catch(() => { /* Error. Inform the user */ }) }
這裡我們使用FormData
,這是一個內置的瀏覽器 API,用於創建發送到服務器的表單數據。 然後,我們使用fetch
API 將圖像實際發送到服務器。 確保更改 URL 以與後端或服務一起使用,並formData.append
您可能需要的任何其他表單數據,以便為服務器提供所需的所有信息。 或者,如果你想支持 Internet Explorer,你可能想使用XMLHttpRequest
,這意味著uploadFile
看起來像這樣:
function uploadFile(file) { var url = 'YOUR URL HERE' var xhr = new XMLHttpRequest() var formData = new FormData() xhr.open('POST', url, true) xhr.addEventListener('readystatechange', function(e) { if (xhr.readyState == 4 && xhr.status == 200) { // Done. Inform the user } else if (xhr.readyState == 4 && xhr.status != 200) { // Error. Inform the user } }) formData.append('file', file) xhr.send(formData) }
根據您的服務器的設置方式,您可能希望檢查不同範圍的status
編號,而不僅僅是200
,但出於我們的目的,這將起作用。
附加的功能
這就是所有基本功能,但通常我們需要更多功能。 具體來說,在本教程中,我們將添加一個預覽窗格,向用戶顯示所有選擇的圖像,然後我們將添加一個進度條,讓用戶查看上傳進度。 所以,讓我們開始預覽圖像。
圖像預覽
有幾種方法可以做到這一點:你可以等到圖片上傳後,讓服務器發送圖片的 URL,但這意味著你需要等待,有時圖片會很大。 另一種方法——我們今天將要探討的——是對我們從drop
事件收到的文件數據使用 FileReader API。 這是異步的,您也可以使用 FileReaderSync,但我們可能會嘗試連續讀取多個大文件,因此這可能會阻塞線程很長一段時間並真正破壞體驗。 所以讓我們創建一個previewFile
函數,看看它是如何工作的:
function previewFile(file) { let reader = new FileReader() reader.readAsDataURL(file) reader.onloadend = function() { let img = document.createElement('img') img.src = reader.result document.getElementById('gallery').appendChild(img) } }
在這裡,我們創建一個new FileReader
並使用File
對像在其上調用readAsDataURL
。 如前所述,這是異步的,因此我們需要添加一個onloadend
事件處理程序才能獲得讀取的結果。 然後,我們使用 base 64 數據 URL 作為新圖像元素的src
,並將其添加到gallery
元素。 現在只需要做兩件事就可以完成這項工作:添加gallery
元素,並確保實際調用previewFile
。
首先,在form
標籤的末尾添加以下 HTML:
<div></div>
沒什麼特別的; 這只是一個div。 已經為它和其中的圖像指定了樣式,所以那裡沒有什麼可做的。 現在讓我們將handleFiles
函數更改為以下內容:
function handleFiles(files) { files = [...files] files.forEach(uploadFile) files.forEach(previewFile) }
有幾種方法可以做到這一點,例如組合,或對forEach
的單個回調,在其中運行uploadFile
和previewFile
,但這也有效。 這樣,當您刪除或選擇一些圖像時,它們應該幾乎立即顯示在表單下方。 有趣的是,在某些應用程序中,您實際上可能不想上傳圖像,而是將它們的數據 URL 存儲在localStorage
或其他一些客戶端緩存中,以供應用程序稍後訪問。 我個人想不出任何好的用例,但我敢打賭有一些。
跟踪進度
如果某件事可能需要一段時間,進度條可以幫助用戶意識到實際正在取得進展,並指示完成需要多長時間。 借助 HTML5 progress
標籤,添加進度指示器非常容易。 這次讓我們先將其添加到 HTML 代碼中。
<progress max=100 value=0></progress>
您可以將其放在label
之後或form
和畫廊div
之間,無論您更喜歡哪個。 就此而言,您可以將其放置在body
標籤中的任何位置。 此示例沒有添加任何樣式,因此它將顯示瀏覽器的默認實現,這是可用的。 現在讓我們開始添加 JavaScript。 我們將首先查看使用fetch
的實現,然後我們將展示XMLHttpRequest
的版本。 首先,我們需要在腳本頂部添加幾個新變量:
let filesDone = 0 let filesToDo = 0 let progressBar = document.getElementById('progress-bar')
使用fetch
時,我們只能確定上傳何時完成,因此我們跟踪的唯一信息是選擇上傳的文件數量(如filesToDo
)和已完成上傳的文件數量(如filesDone
)。 我們還保留了對#progress-bar
元素的引用,以便我們可以快速更新它。 現在讓我們創建幾個函數來管理進度:
function initializeProgress(numfiles) { progressBar.value = 0 filesDone = 0 filesToDo = numfiles } function progressDone() { filesDone++ progressBar.value = filesDone / filesToDo * 100 }
當我們開始上傳時,會調用initializeProgress
來重置進度條。 然後,對於每個完成的上傳,我們將調用progressDone
來增加已完成上傳的數量並更新進度條以顯示當前進度。 所以讓我們通過更新幾個舊函數來調用這些函數:
function handleFiles(files) { files = [...files] initializeProgress(files.length) // <- Add this line files.forEach(uploadFile) files.forEach(previewFile) } function uploadFile(file) { let url = 'YOUR URL HERE' let formData = new FormData() formData.append('file', file) fetch(url, { method: 'POST', body: formData }) .then(progressDone) // <- Add `progressDone` call here .catch(() => { /* Error. Inform the user */ }) }
就是這樣。 現在讓我們看一下XMLHttpRequest
的實現。 我們可以對uploadFile
進行快速更新,但XMLHttpRequest
實際上為我們提供了比fetch
更多的功能,即我們能夠為每個請求的上傳進度添加一個事件偵聽器,它會定期向我們提供有關請求的多少的信息完成的。 因此,我們需要跟踪每個請求的完成百分比,而不是只跟踪完成了多少。 因此,讓我們開始用以下內容替換filesDone
和filesToDo
的聲明:
let uploadProgress = []
然後我們還需要更新我們的函數。 我們將progressDone
重命名為updateProgress
並將它們更改為以下內容:
function initializeProgress(numFiles) { progressBar.value = 0 uploadProgress = [] for(let i = numFiles; i > 0; i--) { uploadProgress.push(0) } } function updateProgress(fileNumber, percent) { uploadProgress[fileNumber] = percent let total = uploadProgress.reduce((tot, curr) => tot + curr, 0) / uploadProgress.length progressBar.value = total }
現在initializeProgress
初始化一個長度等於numFiles
並用零填充的數組,表示每個文件都完成了 0%。 在updateProgress
中,我們找出哪個圖像正在更新其進度,並將該索引處的值更改為提供的percent
。 然後,我們通過取所有百分比的平均值來計算總進度百分比,並更新進度條以反映計算的總數。 我們仍然像在fetch
示例中一樣在handleFiles
中調用initializeProgress
,所以現在我們需要更新的是uploadFile
以調用updateProgress
。
function uploadFile(file, i) { // <- Add `i` parameter var url = 'YOUR URL HERE' var xhr = new XMLHttpRequest() var formData = new FormData() xhr.open('POST', url, true) // Add following event listener xhr.upload.addEventListener("progress", function(e) { updateProgress(i, (e.loaded * 100.0 / e.total) || 100) }) xhr.addEventListener('readystatechange', function(e) { if (xhr.readyState == 4 && xhr.status == 200) { // Done. Inform the user } else if (xhr.readyState == 4 && xhr.status != 200) { // Error. Inform the user } }) formData.append('file', file) xhr.send(formData) }
首先要注意的是我們添加了一個i
參數。 這是文件列表中文件的索引。 我們不需要更新handleFiles
來傳遞這個參數,因為它正在使用forEach
,它已經將元素的索引作為回調的第二個參數。 我們還將progress
事件偵聽器添加到xhr.upload
,以便我們可以使用進度調用updateProgress
。 事件對象(在代碼中稱為e
)有兩條相關信息: loaded
包含到目前為止已上傳的字節數, total
包含文件的總字節數。
|| 100
|| 100
塊在那裡,因為有時如果有錯誤, e.loaded
和e.total
將為零,這意味著計算將作為NaN
出來,因此使用100
來報告文件已完成。 您也可以使用0
。 在任何一種情況下,錯誤都會顯示在readystatechange
處理程序中,以便您可以通知用戶它們。 這只是為了防止嘗試使用NaN
進行數學運算而引發異常。
結論
那是最後一塊。 您現在有一個網頁,您可以在其中通過拖放上傳圖像,立即預覽正在上傳的圖像,並在進度條中查看上傳進度。 您可以在 CodePen 上看到最終版本(使用XMLHttpRequest
),但請注意,我將文件上傳到的服務有限制,所以如果很多人測試它,它可能會中斷一段時間。