Como fazer um uploader de arquivos de arrastar e soltar com Vanilla JavaScript
Publicados: 2022-03-10É um fato conhecido que as entradas de seleção de arquivo são difíceis de estilizar da maneira que os desenvolvedores desejam, então muitos simplesmente o ocultam e criam um botão que abre a caixa de diálogo de seleção de arquivo. Hoje em dia, porém, temos uma maneira ainda mais sofisticada de lidar com a seleção de arquivos: arrastar e soltar.
Tecnicamente, isso já era possível porque a maioria (se não todas ) implementações da entrada de seleção de arquivo permitiam que você arrastasse arquivos sobre ela para selecioná-los, mas isso requer que você realmente mostre o elemento de file
. Então, vamos usar as APIs fornecidas a nós pelo navegador para implementar um seletor e uploader de arquivos de arrastar e soltar.
Neste artigo, usaremos JavaScript ES2015+ “vanilla” (sem frameworks ou bibliotecas) para concluir este projeto, e supõe-se que você tenha um conhecimento prático de JavaScript no navegador. Este exemplo - além da sintaxe ES2015+, que pode ser facilmente alterada para a sintaxe ES5 ou transpilada pelo Babel - deve ser compatível com todos os navegadores evergreen mais IE 10 e 11.
Aqui está uma rápida olhada no que você vai fazer:
Eventos de arrastar e soltar
A primeira coisa que precisamos discutir são os eventos relacionados ao arrastar e soltar, porque eles são a força motriz por trás desse recurso. Ao todo, há oito eventos que o navegador dispara relacionados a arrastar e soltar: drag
, dragend
, dragenter
, dragexit
, dragleave
, dragover
, dragstart
e drop
. Não examinaremos todos eles porque drag
, dragend
, dragexit
e dragstart
são todos acionados no elemento que está sendo arrastado e, em nosso caso, arrastaremos arquivos do nosso sistema de arquivos em vez de elementos DOM , para que esses eventos nunca apareçam.
Se você está curioso sobre eles, você pode ler alguma documentação sobre esses eventos no MDN.
Como você pode esperar, você pode registrar manipuladores de eventos para esses eventos da mesma forma que registra manipuladores de eventos para a maioria dos eventos do navegador: via 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)
Aqui está uma pequena tabela descrevendo o que esses eventos fazem, usando dropArea
do exemplo de código para tornar a linguagem mais clara:
Evento | Quando é demitido? |
---|---|
dragenter | O item arrastado é arrastado sobre dropArea, tornando-o o destino do evento drop se o usuário o soltar lá. |
dragleave | O item arrastado é arrastado de dropArea para outro elemento, tornando-o o destino do evento drop. |
dragover | A cada poucas centenas de milissegundos, enquanto o item arrastado está sobre dropArea e está se movendo. |
drop | O usuário solta o botão do mouse, soltando o item arrastado em dropArea. |
Observe que o item arrastado é arrastado sobre um filho de dropArea
, dragleave
será acionado em dropArea
e dragenter
será acionado nesse elemento filho porque é o novo target
. O evento drop
será propagado até dropArea
(a menos que a propagação seja interrompida por um ouvinte de evento diferente antes de chegar lá), então ele ainda será acionado em dropArea
apesar de não ser o target
do evento.
Observe também que, para criar interações personalizadas de arrastar e soltar, você precisará chamar event.preventDefault()
em cada um dos ouvintes desses eventos. Se você não fizer isso, o navegador acabará abrindo o arquivo que você soltou em vez de enviá-lo para o manipulador de eventos de drop
.
Configurando nosso formulário
Antes de começarmos a adicionar a funcionalidade de arrastar e soltar, precisaremos de um formulário básico com uma entrada de file
padrão. Tecnicamente isso não é necessário, mas é uma boa ideia fornecê-lo como alternativa caso o usuário tenha um navegador sem suporte para a API de arrastar e soltar.
<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>
Estrutura bastante simples. Você pode notar um manipulador onchange
na input
. Vamos dar uma olhada nisso mais tarde. Também seria uma boa ideia adicionar uma action
ao form
e um botão de submit
para ajudar as pessoas que não têm JavaScript habilitado. Então você pode usar JavaScript para se livrar deles para uma forma mais limpa. De qualquer forma, você precisará de um script do lado do servidor para aceitar o upload, seja algo desenvolvido internamente ou esteja usando um serviço como o Cloudinary para fazer isso por você. Além dessas notas, não há nada de especial aqui, então vamos colocar alguns estilos:
#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; }
Muitos desses estilos ainda não estão entrando em jogo, mas tudo bem. Os destaques, por enquanto, são que a entrada do file
está oculta, mas seu label
é estilizado para se parecer com um botão, para que as pessoas percebam que podem clicar nele para abrir a caixa de diálogo de seleção de arquivo. Também estamos seguindo uma convenção ao delinear a área de lançamento com linhas tracejadas.
Adicionando a funcionalidade de arrastar e soltar
Agora chegamos ao cerne da situação: arraste e solte. Vamos colocar um script na parte inferior da página, ou em um arquivo separado, como você quiser. A primeira coisa que precisamos no script é uma referência à área de soltar para que possamos anexar alguns eventos a ela:
let dropArea = document.getElementById('drop-area')
Agora vamos adicionar alguns eventos. Começaremos adicionando manipuladores a todos os eventos para evitar comportamentos padrão e impedir que os eventos borbulhem mais do que o necessário:
;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, preventDefaults, false) }) function preventDefaults (e) { e.preventDefault() e.stopPropagation() }
Agora vamos adicionar um indicador para que o usuário saiba que ele realmente arrastou o item sobre a área correta usando CSS para alterar a cor da borda da área para soltar. Os estilos já devem estar lá no seletor #drop-area.highlight
, então vamos usar JS para adicionar e remover essa classe de highlight
quando necessário.
;['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') }
Tivemos que usar dragenter
e dragover
para o realce por causa do que mencionei anteriormente. Se você começar a passar o mouse diretamente sobre dropArea
e depois passar o mouse sobre um de seus filhos, o dragleave
será acionado e o realce será removido. O evento dragover
é acionado após os eventos dragenter
e dragleave
, então o realce será adicionado de volta ao dropArea
antes de vermos que ele está sendo removido.
Também removemos o destaque quando o item arrastado sai da área designada ou quando você solta o item.
Agora tudo o que precisamos fazer é descobrir o que fazer quando alguns arquivos são descartados:
dropArea.addEventListener('drop', handleDrop, false) function handleDrop(e) { let dt = e.dataTransfer let files = dt.files handleFiles(files) }
Isso não nos leva nem perto da conclusão, mas faz duas coisas importantes:
- Demonstra como obter os dados dos arquivos que foram descartados.
- Nos leva ao mesmo lugar em que a
input
dofile
estava com seu manipuladoronchange
: aguardandohandleFiles
.
Lembre-se de que os files
não são uma matriz, mas uma FileList
. Então, quando implementarmos handleFiles
, precisaremos convertê-lo em um array para iterar mais facilmente:
function handleFiles(files) { ([...files]).forEach(uploadFile) }
Isso foi anticlimático. Vamos entrar em uploadFile
para as coisas realmente carnudas.
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 */ }) }
Aqui usamos FormData
, uma API de navegador integrada para criar dados de formulário para enviar ao servidor. Em seguida, usamos a API de fetch
para enviar a imagem para o servidor. Certifique-se de alterar a URL para trabalhar com seu back-end ou serviço e formData.append
quaisquer dados de formulário adicionais necessários para fornecer ao servidor todas as informações necessárias. Como alternativa, se você quiser oferecer suporte ao Internet Explorer, poderá usar XMLHttpRequest
, o que significa que uploadFile
ficaria assim:
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) }
Dependendo de como seu servidor está configurado, você pode querer verificar diferentes intervalos de números de status
em vez de apenas 200
, mas para nossos propósitos, isso funcionará.
Características adicionais
Essa é toda a funcionalidade básica, mas geralmente queremos mais funcionalidades. Especificamente, neste tutorial, adicionaremos um painel de visualização que exibe todas as imagens escolhidas para o usuário e, em seguida, adicionaremos uma barra de progresso que permite ao usuário ver o progresso dos uploads. Então, vamos começar com a visualização de imagens.
Pré-visualização de imagem
Existem algumas maneiras de fazer isso: você pode esperar até que a imagem seja carregada e pedir ao servidor para enviar o URL da imagem, mas isso significa que você precisa esperar e as imagens podem ser muito grandes às vezes. A alternativa — que exploraremos hoje — é usar a API FileReader nos dados do arquivo que recebemos do evento drop
. Isso é assíncrono e você pode usar o FileReaderSync como alternativa, mas poderíamos estar tentando ler vários arquivos grandes em uma linha, então isso poderia bloquear o encadeamento por um bom tempo e realmente arruinar a experiência. Então vamos criar uma função previewFile
e ver como ela funciona:
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) } }
Aqui criamos um new FileReader
e chamamos readAsDataURL
nele com o objeto File
. Como mencionado, isso é assíncrono, portanto, precisamos adicionar um manipulador de eventos onloadend
para obter o resultado da leitura. Em seguida, usamos o URL de dados de base 64 como o src
para um novo elemento de imagem e o adicionamos ao elemento da gallery
. Há apenas duas coisas que precisam ser feitas para que isso funcione agora: adicione o elemento de gallery
e certifique-se de que previewFile
seja realmente chamado.
Primeiro, adicione o seguinte HTML logo após o final da tag do form
:
<div></div>
Nada especial; é apenas uma div. Os estilos já estão especificados para ele e as imagens nele, então não há mais nada a fazer lá. Agora vamos alterar a função handleFiles
para o seguinte:
function handleFiles(files) { files = [...files] files.forEach(uploadFile) files.forEach(previewFile) }
Existem algumas maneiras de fazer isso, como composição ou um único retorno de chamada para forEach
que executou uploadFile
e previewFile
nele, mas isso também funciona. E com isso, ao soltar ou selecionar algumas imagens, elas devem aparecer quase que instantaneamente abaixo do formulário. O interessante disso é que - em certos aplicativos - você pode não querer fazer upload de imagens, mas armazenar as URLs de dados delas em localStorage
ou algum outro cache do lado do cliente para ser acessado pelo aplicativo posteriormente. Pessoalmente, não consigo pensar em nenhum bom caso de uso para isso, mas estou disposto a apostar que existem alguns.
Acompanhamento do progresso
Se algo demorar um pouco, uma barra de progresso pode ajudar o usuário a perceber que o progresso está realmente sendo feito e dar uma indicação de quanto tempo levará para ser concluído. Adicionar um indicador de progresso é muito fácil graças à tag de progress
HTML5. Vamos começar adicionando isso ao código HTML desta vez.
<progress max=100 value=0></progress>
Você pode colocá-lo logo após o label
ou entre o form
e o div
da galeria, o que você preferir. Nesse caso, você pode colocá-lo onde quiser dentro das tags do body
. Nenhum estilo foi adicionado para este exemplo, então ele mostrará a implementação padrão do navegador, que pode ser reparada. Agora vamos trabalhar para adicionar o JavaScript. Veremos primeiro a implementação usando fetch
e, em seguida, mostraremos uma versão para XMLHttpRequest
. Para começar, precisaremos de algumas novas variáveis na parte superior do script:
let filesDone = 0 let filesToDo = 0 let progressBar = document.getElementById('progress-bar')
Ao usar fetch
, só podemos determinar quando um upload é concluído, portanto, as únicas informações que rastreamos são quantos arquivos são selecionados para upload (como filesToDo
) e o número de arquivos que terminaram de enviar (como filesDone
). Também estamos mantendo uma referência ao elemento #progress-bar
para que possamos atualizá-lo rapidamente. Agora vamos criar algumas funções para gerenciar o progresso:
function initializeProgress(numfiles) { progressBar.value = 0 filesDone = 0 filesToDo = numfiles } function progressDone() { filesDone++ progressBar.value = filesDone / filesToDo * 100 }
Quando iniciarmos o upload, initializeProgress
será chamado para redefinir a barra de progresso. Então, com cada upload concluído, chamaremos progressDone
para aumentar o número de uploads concluídos e atualizar a barra de progresso para mostrar o progresso atual. Então, vamos chamar essas funções atualizando algumas funções antigas:
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 é isso. Agora vamos dar uma olhada na implementação XMLHttpRequest
. Poderíamos apenas fazer uma atualização rápida para uploadFile
, mas XMLHttpRequest
na verdade nos dá mais funcionalidade do que fetch
, ou seja, podemos adicionar um ouvinte de evento para o progresso do upload em cada solicitação, que periodicamente nos fornecerá informações sobre quanto da solicitação é finalizado. Por isso, precisamos rastrear a porcentagem de conclusão de cada solicitação, em vez de apenas quantas são feitas. Então, vamos começar substituindo as declarações para filesDone
e filesToDo
pelo seguinte:
let uploadProgress = []
Então precisamos atualizar nossas funções também. Vamos renomear progressDone
para updateProgress
e alterá-los para o seguinte:
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 }
Agora initializeProgress
inicializa um array com um comprimento igual a numFiles
que é preenchido com zeros, denotando que cada arquivo está 0% completo. Em updateProgress
descobrimos qual imagem está tendo seu progresso atualizado e alteramos o valor desse índice para o percent
fornecido. Em seguida, calculamos a porcentagem total de progresso obtendo uma média de todas as porcentagens e atualizamos a barra de progresso para refletir o total calculado. Ainda chamamos initializeProgress
em handleFiles
da mesma forma que fizemos no exemplo de fetch
, então agora tudo o que precisamos atualizar é uploadFile
para chamar 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) }
A primeira coisa a notar é que adicionamos um parâmetro i
. Este é o índice do arquivo na lista de arquivos. Não precisamos atualizar handleFiles
para passar esse parâmetro porque ele está usando forEach
, que já dá o índice do elemento como segundo parâmetro para callbacks. Também adicionamos o ouvinte de eventos de progress
ao xhr.upload
para que possamos chamar updateProgress
com o progresso. O objeto de evento (referido como e
no código) tem duas informações pertinentes sobre ele: loaded
que contém o número de bytes que foram carregados até agora e total
que contém o número de bytes que o arquivo está no total.
O || 100
|| 100
piece está lá porque às vezes se houver um erro, e.loaded
e e.total
serão zero, o que significa que o cálculo sairá como NaN
, então o 100
é usado para informar que o arquivo está pronto. Você também pode usar 0
. Em ambos os casos, o erro aparecerá no manipulador readystatechange
para que você possa informar o usuário sobre eles. Isso é apenas para evitar que exceções sejam lançadas para tentar fazer contas com NaN
.
Conclusão
Essa é a peça final. Agora você tem uma página da web onde você pode fazer upload de imagens por meio de arrastar e soltar, visualizar as imagens sendo carregadas imediatamente e ver o progresso do upload em uma barra de progresso. Você pode ver a versão final (com XMLHttpRequest
) em ação no CodePen, mas esteja ciente de que o serviço para o qual eu carrego os arquivos tem limites, então se muitas pessoas testarem, ele pode quebrar por um tempo.