Come creare un caricatore di file drag-and-drop con JavaScript Vanilla

Pubblicato: 2022-03-10
Riepilogo rapido ↬ 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 dovrebbe essere compatibile con tutti i browser evergreen più IE 10 e 11.

È 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:

Caricatore di immagini drag-and-drop in azione
Una dimostrazione di una pagina Web in cui è possibile caricare immagini tramite trascinamento della selezione, visualizzare in anteprima le immagini caricate immediatamente e vedere l'avanzamento del caricamento in una barra di avanzamento.

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.

Altro dopo il salto! Continua a leggere sotto ↓

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:

  1. Illustra come ottenere i dati per i file che sono stati eliminati.
  2. Ci porta nello stesso punto in cui si trovava l' input del file con il relativo gestore onchange : in attesa di handleFiles .

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'.