Come creare un caricatore di file drag-and-drop con JavaScript Vanilla
Pubblicato: 2022-03-10È risaputo che gli input per la selezione dei file sono difficili da definire come vogliono gli sviluppatori, quindi molti semplicemente lo nascondono e creano un pulsante che apre invece la finestra di dialogo di selezione dei file. Al giorno d'oggi, tuttavia, abbiamo un modo ancora più elaborato per gestire la selezione dei file: trascinare e rilasciare.
Tecnicamente, questo era già possibile perché la maggior parte (se non tutte ) delle implementazioni dell'input di selezione del file ti consentivano di trascinare i file su di esso per selezionarli, ma ciò richiede di mostrare effettivamente l'elemento del file
. Quindi, utilizziamo effettivamente le API forniteci dal browser per implementare un selettore di file e un caricatore di trascinamento della selezione.
In questo articolo, utilizzeremo JavaScript ES2015+ "vanilla" (senza framework o librerie) per completare questo progetto e si presume che tu abbia una conoscenza pratica di JavaScript nel browser. Questo esempio, a parte la sintassi ES2015+, che può essere facilmente modificata in ES5 o trasferita da Babel, dovrebbe essere compatibile con tutti i browser evergreen più IE 10 e 11.
Ecco una rapida occhiata a cosa farai:
Eventi trascina e rilascia
La prima cosa di cui dobbiamo discutere sono gli eventi relativi al drag-and-drop perché sono la forza trainante di questa funzionalità. In tutto, ci sono otto eventi che il browser attiva in relazione al drag and drop: drag
, dragend
, dragenter
, dragexit
, dragleave
, dragover
, dragstart
e drop
. Non li esamineremo tutti perché drag
, dragend
, dragexit
e dragstart
vengono tutti attivati sull'elemento che viene trascinato e, nel nostro caso, trascineremo i file dal nostro file system anziché dagli elementi DOM , quindi questi eventi non verranno mai visualizzati.
Se sei curioso di conoscerli, puoi leggere della documentazione su questi eventi su MDN.
Come ci si potrebbe aspettare, puoi registrare i gestori di eventi per questi eventi nello stesso modo in cui registri i gestori di eventi per la maggior parte degli eventi del browser: tramite 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)
Ecco una piccola tabella che descrive cosa fanno questi eventi, usando dropArea
dal codice di esempio per rendere il linguaggio più chiaro:
Evento | Quando viene licenziato? |
---|---|
dragenter | L'elemento trascinato viene trascinato su dropArea, rendendolo la destinazione dell'evento di rilascio se l'utente lo rilascia lì. |
dragleave | L'elemento trascinato viene trascinato fuori da dropArea e su un altro elemento, rendendolo invece la destinazione dell'evento di rilascio. |
dragover | Ogni poche centinaia di millisecondi, mentre l'elemento trascinato è sopra dropArea e si sta muovendo. |
drop | L'utente rilascia il pulsante del mouse, rilasciando l'elemento trascinato su dropArea. |
Nota che l'elemento trascinato viene trascinato su un elemento figlio di dropArea
, dragleave
si attiverà su dropArea
e dragenter
si attiverà su quell'elemento figlio perché è il nuovo target
. L'evento drop
si propagherà fino a dropArea
(a meno che la propagazione non venga interrotta da un altro listener di eventi prima che arrivi lì), quindi si attiverà comunque su dropArea
nonostante non sia la target
dell'evento.
Tieni inoltre presente che per creare interazioni di trascinamento della selezione personalizzate, dovrai chiamare event.preventDefault()
in ciascuno dei listener per questi eventi. In caso contrario, il browser finirà per aprire il file che hai eliminato invece di inviarlo al gestore dell'evento di drop
.
Impostazione del nostro modulo
Prima di iniziare ad aggiungere la funzionalità di trascinamento della selezione, avremo bisogno di un modulo di base con un input di file
standard. Tecnicamente questo non è necessario, ma è una buona idea fornirlo come alternativa nel caso in cui l'utente abbia un browser senza supporto per l'API drag-and-drop.
<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>
Struttura abbastanza semplice. Potresti notare un gestore onchange
input
. Daremo un'occhiata più avanti. Sarebbe anche una buona idea aggiungere action
al form
e un pulsante di submit
per aiutare le persone che non hanno JavaScript abilitato. Quindi puoi usare JavaScript per sbarazzartene per un modulo più pulito. In ogni caso, avrai bisogno di uno script lato server per accettare il caricamento, sia che si tratti di qualcosa sviluppato internamente, sia che tu stia utilizzando un servizio come Cloudinary per farlo per te. A parte queste note, non c'è niente di speciale qui, quindi aggiungiamo alcuni stili:
#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; }
Molti di questi stili non stanno ancora entrando in gioco, ma va bene. I punti salienti, per ora, sono che l'input del file
è nascosto, ma la sua label
lo stile di un pulsante, quindi le persone si renderanno conto che possono fare clic su di esso per visualizzare la finestra di dialogo di selezione del file. Stiamo anche seguendo una convenzione delineando l'area di rilascio con linee tratteggiate.
Aggiunta della funzionalità di trascinamento della selezione
Ora veniamo al nocciolo della situazione: trascina e rilascia. Mettiamo uno script in fondo alla pagina, o in un file separato, come preferisci. La prima cosa di cui abbiamo bisogno nello script è un riferimento all'area di rilascio in modo da potervi allegare alcuni eventi:
let dropArea = document.getElementById('drop-area')
Ora aggiungiamo alcuni eventi. Inizieremo con l'aggiunta di gestori a tutti gli eventi per prevenire comportamenti predefiniti e impedire che gli eventi si espandano più del necessario:
;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, preventDefaults, false) }) function preventDefaults (e) { e.preventDefault() e.stopPropagation() }
Ora aggiungiamo un indicatore per far sapere all'utente che ha effettivamente trascinato l'elemento sull'area corretta usando CSS per cambiare il colore del bordo dell'area di rilascio. Gli stili dovrebbero essere già presenti sotto il selettore #drop-area.highlight
, quindi usiamo JS per aggiungere e rimuovere quella classe di highlight
quando necessario.
;['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') }
Abbiamo dovuto usare sia dragenter
che dragover
per l'evidenziazione a causa di ciò che ho menzionato prima. Se inizi a passare con il mouse direttamente su dropArea
e poi passa il mouse su uno dei suoi figli, il dragleave
verrà attivato e l'evidenziazione verrà rimossa. L'evento dragover
viene attivato dopo gli eventi dragenter
e dragleave
, quindi l'evidenziazione verrà aggiunta nuovamente a dropArea
prima che venga rimossa.
Rimuoviamo anche l'evidenziazione quando l'elemento trascinato lascia l'area designata o quando si rilascia l'elemento.
Ora tutto ciò che dobbiamo fare è capire cosa fare quando alcuni file vengono eliminati:
dropArea.addEventListener('drop', handleDrop, false) function handleDrop(e) { let dt = e.dataTransfer let files = dt.files handleFiles(files) }
Questo non ci porta da nessuna parte vicino al completamento, ma fa due cose importanti:
- Illustra come ottenere i dati per i file che sono stati eliminati.
- Ci porta nello stesso punto in cui si trovava l'
input
delfile
con il relativo gestoreonchange
: in attesa dihandleFiles
.
Tieni presente che files
non sono un array, ma un FileList
. Quindi, quando implementiamo handleFiles
, dovremo convertirlo in un array per poterlo scorrere più facilmente:
function handleFiles(files) { ([...files]).forEach(uploadFile) }
È stato deludente. Entriamo in uploadFile
per le cose davvero carnose.
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 */ }) }
Qui utilizziamo FormData
, un'API del browser integrata per la creazione di dati di moduli da inviare al server. Quindi utilizziamo l'API di fetch
per inviare effettivamente l'immagine al server. Assicurati di modificare l'URL in modo che funzioni con il tuo back-end o servizio e formData.append
eventuali dati aggiuntivi del modulo di cui potresti aver bisogno per fornire al server tutte le informazioni di cui ha bisogno. In alternativa, se desideri supportare Internet Explorer, potresti voler utilizzare XMLHttpRequest
, il che significa che uploadFile
sarebbe invece simile a questo:
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) }
A seconda di come è impostato il tuo server, potresti voler controllare diversi intervalli di numeri di status
anziché solo 200
, ma per i nostri scopi funzionerà.
Caratteristiche aggiuntive
Queste sono tutte le funzionalità di base, ma spesso vogliamo più funzionalità. In particolare, in questo tutorial, aggiungeremo un riquadro di anteprima che mostra tutte le immagini scelte all'utente, quindi aggiungeremo una barra di avanzamento che consente all'utente di vedere l'avanzamento dei caricamenti. Quindi, iniziamo con l'anteprima delle immagini.
Anteprima immagine
Ci sono un paio di modi per farlo: potresti aspettare fino a dopo che l'immagine è stata caricata e chiedere al server di inviare l'URL dell'immagine, ma ciò significa che devi aspettare e le immagini possono essere piuttosto grandi a volte. L'alternativa, che esploreremo oggi, consiste nell'utilizzare l'API FileReader sui dati del file che abbiamo ricevuto dall'evento di drop
. Questo è asincrono e in alternativa potresti usare FileReaderSync, ma potremmo provare a leggere diversi file di grandi dimensioni di seguito, quindi questo potrebbe bloccare il thread per un po' e rovinare davvero l'esperienza. Quindi creiamo una funzione previewFile
e vediamo come funziona:
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) } }
Qui creiamo un new FileReader
e chiamiamo readAsDataURL
su di esso con l'oggetto File
. Come accennato, questo è asincrono, quindi è necessario aggiungere un gestore di eventi onloadend
per ottenere il risultato della lettura. Usiamo quindi l'URL dei dati di base 64 come src
per un nuovo elemento immagine e lo aggiungiamo all'elemento gallery
. Ci sono solo due cose che devono essere fatte per farlo funzionare ora: aggiungi l'elemento gallery
e assicurati che previewFile
sia effettivamente chiamato.
Innanzitutto, aggiungi il seguente codice HTML subito dopo la fine del tag del form
:
<div></div>
Niente di speciale; è solo un div. Gli stili sono già specificati per esso e le immagini in esso contenute, quindi non c'è più niente da fare lì. Ora cambiamo la funzione handleFiles
come segue:
function handleFiles(files) { files = [...files] files.forEach(uploadFile) files.forEach(previewFile) }
Ci sono alcuni modi in cui avresti potuto farlo, come la composizione o un singolo callback a forEach
che ha eseguito uploadFile
e previewFile
al suo interno, ma anche questo funziona. E con ciò, quando rilasci o selezioni alcune immagini, dovrebbero apparire quasi istantaneamente sotto il modulo. La cosa interessante è che, in alcune applicazioni, potresti non voler effettivamente caricare immagini, ma invece archiviare gli URL dei dati di esse in localStorage
o in qualche altra cache lato client a cui l'app può accedere in seguito. Personalmente non riesco a pensare a nessun buon caso d'uso per questo, ma sono disposto a scommettere che ce ne sono alcuni.
Monitoraggio dei progressi
Se qualcosa potrebbe richiedere del tempo, una barra di avanzamento può aiutare un utente a rendersi conto che i progressi sono effettivamente in corso e dare un'indicazione di quanto tempo ci vorrà per essere completato. L'aggiunta di un indicatore di avanzamento è piuttosto semplice grazie al tag di progress
HTML5. Iniziamo aggiungendolo al codice HTML questa volta.
<progress max=100 value=0></progress>
Puoi inserirlo subito dopo l' label
o tra il form
e la galleria div
, a seconda di quale preferisci. Del resto, puoi posizionarlo dove vuoi all'interno dei tag del body
. Nessuno stile è stato aggiunto per questo esempio, quindi mostrerà l'implementazione predefinita del browser, che è utile. Ora lavoriamo sull'aggiunta di JavaScript. Per prima cosa esamineremo l'implementazione usando fetch
e poi mostreremo una versione per XMLHttpRequest
. Per iniziare, avremo bisogno di un paio di nuove variabili nella parte superiore dello script:
let filesDone = 0 let filesToDo = 0 let progressBar = document.getElementById('progress-bar')
Quando si utilizza fetch
siamo solo in grado di determinare quando un caricamento è terminato, quindi le uniche informazioni che tracciamo sono il numero di file selezionati da caricare (come filesToDo
) e il numero di file che hanno terminato il caricamento (come filesDone
). Manteniamo anche un riferimento all'elemento #progress-bar
modo da poterlo aggiornare rapidamente. Ora creiamo un paio di funzioni per la gestione dell'avanzamento:
function initializeProgress(numfiles) { progressBar.value = 0 filesDone = 0 filesToDo = numfiles } function progressDone() { filesDone++ progressBar.value = filesDone / filesToDo * 100 }
Quando iniziamo il caricamento, verrà chiamato initializeProgress
per ripristinare la barra di avanzamento. Quindi, a ogni caricamento completato, chiameremo progressDone
per aumentare il numero di caricamenti completati e aggiornare la barra di avanzamento per mostrare l'avanzamento corrente. Quindi chiamiamo queste funzioni aggiornando un paio di vecchie funzioni:
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 */ }) }
E questo è tutto. Ora diamo un'occhiata all'implementazione di XMLHttpRequest
. Potremmo semplicemente fare un rapido aggiornamento per uploadFile
, ma XMLHttpRequest
in realtà ci offre più funzionalità di fetch
, ovvero siamo in grado di aggiungere un listener di eventi per l'avanzamento del caricamento su ogni richiesta, che ci fornirà periodicamente informazioni su quanto della richiesta è finito. Per questo motivo, dobbiamo tenere traccia della percentuale di completamento di ciascuna richiesta anziché solo di quante ne vengono eseguite. Quindi, iniziamo con la sostituzione delle dichiarazioni per filesDone
e filesToDo
con quanto segue:
let uploadProgress = []
Quindi dobbiamo aggiornare anche le nostre funzioni. updateProgress
progressDone
per aggiornareProgress e cambiarli in modo che siano i seguenti:
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 }
Ora initializeProgress
inizializza una matrice con una lunghezza uguale a numFiles
che viene riempita con zero, indicando che ogni file è completo allo 0%. In updateProgress
scopriamo quale immagine è in fase di aggiornamento e cambiamo il valore in quell'indice alla percent
fornita. Quindi calcoliamo la percentuale di avanzamento totale prendendo una media di tutte le percentuali e aggiorniamo la barra di avanzamento per riflettere il totale calcolato. Chiamiamo ancora initializeProgress
in handleFiles
come abbiamo fatto nell'esempio di fetch
, quindi ora tutto ciò che dobbiamo aggiornare è uploadFile
per chiamare 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) }
La prima cosa da notare è che abbiamo aggiunto un parametro i
. Questo è l'indice del file nell'elenco dei file. Non è necessario aggiornare handleFiles
per passare questo parametro perché utilizza forEach
, che fornisce già l'indice dell'elemento come secondo parametro ai callback. Abbiamo anche aggiunto il listener di eventi progress
a xhr.upload
in modo da poter chiamare updateProgress
con lo stato di avanzamento. L'oggetto evento (indicato come e
nel codice) contiene due informazioni pertinenti: loaded
che contiene il numero di byte che sono stati caricati finora e total
che contiene il numero di byte in cui il file è in totale.
Il || 100
|| 100
pezzi sono lì perché a volte se c'è un errore, e.loaded
ed e.total
saranno zero, il che significa che il calcolo risulterà come NaN
, quindi il 100
viene utilizzato invece per segnalare che il file è terminato. Potresti anche usare 0
. In entrambi i casi, l'errore verrà visualizzato nel gestore readystatechange
in modo che tu possa informarne l'utente. Questo è semplicemente per evitare che vengano generate eccezioni per aver provato a fare calcoli con NaN
.
Conclusione
Questo è l'ultimo pezzo. Ora hai una pagina web in cui puoi caricare immagini tramite trascinamento della selezione, visualizzare in anteprima le immagini caricate immediatamente e vedere l'avanzamento del caricamento in una barra di avanzamento. Puoi vedere la versione finale (con XMLHttpRequest
) in azione su CodePen, ma tieni presente che il servizio in cui carico i file ha dei limiti, quindi se molte persone lo testano, potrebbe interrompersi per un po'.