Como Criar Miniaturas de Imagens Antes do Upload
Crie miniaturas antes do upload com pré-visualização FileReader, redimensionamento Canvas toBlob, envio via FormData e campos multer.
Uma pré-visualização exibe o arquivo selecionado pelo usuário em um tamanho menor, mas envia exatamente os mesmos bytes; uma miniatura é uma imagem genuinamente recodificada e menor, que você pode enviar junto com — ou no lugar de — a original.
Se você já entregou o que julgava ser um recurso de miniaturas e depois viu uma foto de celular de 12 MB rastejando até o servidor mesmo assim, já sabe que essa diferença importa. A maioria dos tutoriais confunde essa distinção e chama uma <img> reduzida de “miniatura”, o que está errado: encolher uma imagem na tela com CSS não muda nada no tráfego de rede. Este artigo separa os dois conceitos e depois percorre o caminho desde a pré-visualização mais simples com FileReader até uma miniatura real redimensionada com Canvas, pronta para upload, além das armadilhas de limpeza e validação que quebram interfaces de upload em produção.
Principais Conclusões
- Uma pré-visualização com
FileReader.readAsDataURLé apenas para exibição: ela envia exatamente os bytes originais e não reduz nada no tráfego de rede. - Para produzir um arquivo menor, você precisa redesenhar a imagem em um
<canvas>e recodificá-la comcanvas.toBlob(callback, 'image/jpeg', 0.7). canvas.toBlob()é assíncrono e entrega o Blob a um callback em vez de retorná-lo, então envolva-o em uma Promise para poder usarawaitno resultado.- Envie os dois arquivos em um único
FormData(oFileoriginal e oBlobda miniatura com um nome de arquivo explícito) e receba-os no servidor com oupload.fields()do multer. - Todo
URL.createObjectURL()deve ser acompanhado de umURL.revokeObjectURL(), ou o arquivo subjacente permanece na memória até o documento ser descarregado.
Pré-visualização vs. miniatura real: do que você precisa?
Decida isso antes de escrever qualquer código, porque os dois caminhos praticamente não compartilham lógica. Uma pré-visualização confirma visualmente que o arquivo correto foi selecionado. Uma miniatura é um novo ativo de imagem, menor (menos bytes, dimensões menores), que reduz o tempo de upload e o processamento no servidor, e pode ser armazenada como imagem de grade sem reprocessar a original.
| Objetivo | Técnica | Gera um novo arquivo menor? | Use quando |
|---|---|---|---|
| Mostrar a imagem escolhida instantaneamente | FileReader ou URL.createObjectURL(file) | Não | Você só precisa de confirmação visual |
| Reduzir os bytes enviados / armazenar uma variante pequena | Canvas + toBlob() | Sim | Você faz upload para um servidor ou CDN |
| Arrastar e soltar, progresso, múltiplos tamanhos, validação | Biblioteca (FilePond) | Sim | Você quer tudo pronto de fábrica |
Uma pré-visualização com readAsDataURL do FileReader não encolhe nada no tráfego de rede. Para realmente reduzir os bytes enviados, você precisa redesenhar a imagem em um <canvas> e recodificá-la com canvas.toBlob().
Como pré-visualizar uma imagem com FileReader?
Discover how at OpenReplay.com.
Para uma pré-visualização apenas de exibição, escute o evento change do input de arquivo, leia o arquivo com FileReader.readAsDataURL e atribua a data URL resultante a uma <img> dentro de reader.onload. É a solução mais rápida de entregar, e não redimensiona o arquivo.
<input type="file" id="fileInput" accept="image/*" multiple>
<div id="previews"></div>
const input = document.getElementById('fileInput');
const previews = document.getElementById('previews');
input.addEventListener('change', (e) => {
previews.innerHTML = '';
Array.from(e.target.files).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.src = ev.target.result; // base64 data URL
img.alt = `Preview of ${file.name}`;
previews.appendChild(img);
};
reader.readAsDataURL(file);
});
});
Repare no padrão Array.from(files).forEach(...). O tradicional tutorial de múltiplos arquivos com jQuery reutiliza uma única variável reader dentro de um laço for, de modo que todo onload acaba capturando o último arquivo: um bug de closure que exibe a mesma imagem repetidamente. Cada arquivo precisa do seu próprio FileReader dentro da iteração, algo que o forEach oferece de graça.
Uma alternativa mais leve para arquivos grandes é URL.createObjectURL(file), que retorna uma blob URL curta em vez de uma string base64 e evita o inchaço de memória de codificar o arquivo inteiro em uma data URL, ao custo de exigir um revokeObjectURL() obrigatório depois.
Como gerar uma miniatura redimensionada com Canvas?
Para produzir um arquivo realmente menor, carregue a imagem, escale suas dimensões preservando a proporção, desenhe-a em um canvas e recodifique. Para preservar a proporção, escale ambas as dimensões pelo mesmo fator (maxSize / maiorLado) em vez de definir largura e altura de forma independente.
canvas.toBlob(callback, 'image/jpeg', 0.7) é assíncrono: ele entrega o Blob ao seu callback em vez de retorná-lo, então envolva-o em uma Promise se quiser usar await na miniatura. Um canvas comum não possui forma que retorne promise; apenas OffscreenCanvas.convertToBlob() retorna uma Promise nativamente.
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
type,
quality
);
});
}
async function makeThumbnail(file, maxSize = 200) {
const url = URL.createObjectURL(file);
try {
const img = await new Promise((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = rej;
i.src = url;
});
const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
return await canvasToBlob(canvas, 'image/jpeg', 0.7);
} finally {
URL.revokeObjectURL(url);
}
}
O terceiro argumento de toBlob define a qualidade de codificação numa escala de 0 a 1. Apenas formatos com perdas o consideram, então ele altera a saída para image/jpeg e image/webp e não faz nada para image/png. Um valor entre 0,6 e 0,8 é o ponto ideal na prática. Recodificar uma foto de vários megapixels para um JPEG de 200px normalmente reduz seu tamanho em uma ou duas ordens de grandeza — que é justamente o propósito de fazer isso no cliente.
Exibir a miniatura e enviar os dois arquivos
Exiba o Blob gerado com URL.createObjectURL(blob) e depois envie-o ao servidor junto com o original. Para o upload, adicione ambos os arquivos a um único FormData: o File original e o Blob da miniatura, passando um nome de arquivo como terceiro argumento. Faça o POST com fetch e, no servidor, o upload.fields() do multer recebe os dois campos separadamente.
async function upload(file) {
const thumb = await makeThumbnail(file);
const preview = new Image();
preview.src = URL.createObjectURL(thumb); // remember to revoke later
document.body.appendChild(preview);
const form = new FormData();
form.append('originalFiles', file, file.name);
form.append('thumbnails', thumb, `thumb-${file.name}.jpg`);
await fetch('/api/upload', { method: 'POST', body: form });
}
No backend, o multer faz o parsing do corpo multipart. Os nomes dos campos em upload.fields() devem coincidir exatamente com as chaves de FormData.append, ou o campo é descartado silenciosamente:
const upload = multer({ dest: 'uploads/' });
app.post('/api/upload', upload.fields([
{ name: 'originalFiles', maxCount: 10 },
{ name: 'thumbnails', maxCount: 10 },
]), (req, res) => res.json({ ok: true }));
A linha 2.x do multer traz as correções de segurança que faltam na linha 1.x, e seu package.json define o piso em Node.js 10.16.0, não Node 18. O mínimo de Node 18 chega com a linha 3.x, que ainda está em alpha. A versão estável mais recente no changelog do multer é a 2.2.0, então confira lá antes de fixar uma versão.
A opção de biblioteca: FilePond e companhia
Quando você também quer arrastar e soltar, progresso de upload, validação e múltiplas variantes redimensionadas, opte pelo FilePond com seus plugins de imagem em vez de construir tudo manualmente. O plugin filepond-plugin-image-preview renderiza a pré-visualização, o filepond-plugin-image-resize escreve os metadados de redimensionamento, e o filepond-plugin-image-transform executa o redimensionamento de fato e entrega os Blobs de saída.
FilePond.registerPlugin(
FilePondPluginImagePreview,
FilePondPluginImageResize,
FilePondPluginImageTransform
);
FilePond.create(document.querySelector('input[type="file"]'), {
imageResizeTargetWidth: 256,
imageResizeMode: 'contain',
});
Fixe o FilePond na linha da versão 4 (@^4), como recomenda a documentação de instalação do FilePond. A linha 4.x é a estável, na 4.32.12 segundo o changelog, enquanto a v5 ainda está em beta. Carregar unpkg.com/filepond sem fixar versão acompanha a tag latest, então hoje ele serve a versão estável atual, mas vai te jogar na próxima major no dia em que aquela linha for promovida.
Especificamente para redução de escala com maior qualidade, o Pica (10.0.2) aplica um filtro de reamostragem adequado e pode rodar em um Web Worker; selecione seu algoritmo pela opção filter, por exemplo { filter: 'lanczos3' }. O pacote browser-image-compression (2.0.2) é outra opção, mas seu último lançamento foi em março de 2023 e o Snyk classifica sua manutenção como inativa, então pondere isso antes de adotá-lo.
Armadilhas e boas práticas
Estes são os modos de falha que os session replays de interfaces de upload revelam com frequência (crescimento silencioso de memória, miniaturas rotacionadas e abas que travam com arquivos enormes):
- Revogue as object URLs. Sempre chame
URL.revokeObjectURL()ao remover uma pré-visualização: cadacreateObjectURL()mantém o arquivo subjacente na memória até que a URL seja explicitamente revogada ou o documento seja descarregado. Object URLs sem controle são uma fonte clássica de crescimento gradual de memória na aba. - FileList é somente leitura. Como um
FileListé somente leitura, você não pode remover um arquivo de dentro de um<input>, então mantenha um array editável próprio e reconstrua o upload a partir dele. - Valide antes de processar. Combine
accept="image/*"com uma verificação em tempo de execuçãofile.type.startsWith('image/')e um limite de tamanho.accepté uma dica de UX, não uma imposição. - Limite as dimensões para proteger a aba. Imagens muito grandes podem esgotar a memória e derrubar a aba durante a decodificação; rejeite arquivos acima de um limiar de bytes e restrinja
maxSizeantes de desenhar. - Rotação EXIF. Redimensionar via canvas pode descartar a flag de orientação EXIF, então uma foto de celular em retrato pode sair de lado. Sempre teste com fotos reais em retrato tiradas de um celular.
- Qualidade da redução de escala. Para reduções grandes, um único
drawImage()pode ficar com aspecto grosseiro; redimensione em etapas ou use uma biblioteca como o Pica, que aplica um filtro de reamostragem adequado para miniaturas mais nítidas. - Contaminação do canvas (tainting) afeta apenas imagens cross-origin carregadas de outros domínios; arquivos selecionados pelo usuário nunca contaminam o canvas, então não é preciso lidar com
crossOriginaqui. - Acessibilidade. Dê a cada
<img>de pré-visualização um textoaltsignificativo e rotule os botões de remoção com ARIA, para que a interface seja utilizável sem enxergar a miniatura.
Concluindo
Escolha o caminho que corresponde ao seu objetivo: uma pré-visualização com FileReader ou object URL quando você só precisa de confirmação visual, e uma miniatura via toBlob do Canvas quando você realmente precisa de menos bytes no tráfego de rede. Comece com o helper makeThumbnail envolvido em Promise mostrado acima, envie o original e a miniatura juntos em um único FormData, e implemente a limpeza com revokeObjectURL desde o primeiro commit, em vez de acrescentá-la depois que um vazamento de memória aparecer.
Perguntas Frequentes
Criar uma pré-visualização reduz o tamanho do arquivo que o usuário envia?
Não. Uma pré-visualização com readAsDataURL do FileReader ou com URL.createObjectURL é apenas para exibição e envia exatamente os bytes originais, sem nenhuma redução no tráfego de rede. Para realmente encolher o arquivo enviado, você precisa redesenhar a imagem em um canvas com dimensões menores e recodificá-la com canvas.toBlob, enviando então esse Blob no lugar do original ou junto com ele.
Por que minha miniatura gerada pelo canvas está rotacionada de forma errada?
Redimensionar uma imagem por meio de um canvas pode descartar a flag de orientação EXIF que os celulares armazenam em fotos em retrato, fazendo com que um original corretamente orientado saia de lado na miniatura. Os navegadores orientam automaticamente um elemento img simples, mas o drawImage nem sempre transfere essa orientação para o canvas. Sempre teste seu fluxo de redimensionamento com fotos reais em retrato tiradas de um celular, e não apenas com imagens de teste em paisagem.
Como uso await no canvas.toBlob se ele só aceita um callback?
Envolva-o em uma Promise, pois canvas.toBlob é assíncrono e entrega o Blob ao seu callback em vez de retorná-lo. Crie um helper que retorne new Promise e chame canvas.toBlob com resolve, rejeitando quando o callback receber null. Um canvas comum não possui forma nativa que retorne promise; apenas OffscreenCanvas.convertToBlob retorna uma Promise diretamente.
Por que meu campo de miniatura nunca chega no backend com multer?
Os nomes de campo passados ao upload.fields do multer devem coincidir exatamente com as chaves de FormData.append no cliente, ou o campo é descartado silenciosamente, sem erro. Se você adiciona 'thumbnails' no cliente, seu servidor precisa declarar o name 'thumbnails' em upload.fields. Verifique também sua versão do multer: a linha 2.x traz correções de segurança que faltam na linha 1.x.