Cómo crear miniaturas de imágenes antes de subirlas
Crea miniaturas antes de subir con vistas previas FileReader, redimensionado Canvas toBlob, FormData y coincidencia de campos multer.
Una vista previa muestra el archivo seleccionado por el usuario en un tamaño más pequeño, pero sube exactamente los mismos bytes; una miniatura es una imagen genuinamente recodificada y de menor tamaño que puedes subir junto al original — o en lugar de él.
Si alguna vez has lanzado lo que creías que era una funcionalidad de miniaturas y luego has visto cómo una foto de móvil de 12 MB se arrastraba igualmente hasta el servidor, ya sabes que la diferencia importa. La mayoría de los tutoriales difuminan esta distinción y llaman “miniatura” a un <img> reducido, lo cual es incorrecto: encoger una imagen en pantalla con CSS no cambia nada en la transmisión. Este artículo separa ambos conceptos y luego recorre el camino desde la vista previa más simple con FileReader hasta una miniatura real redimensionada con Canvas que puedes subir, además de los detalles de limpieza y validación que rompen las interfaces de subida en producción.
Puntos clave
- Una vista previa con
FileReader.readAsDataURLes solo para visualización: sube exactamente los bytes originales y no reduce nada en la transmisión. - Para producir un archivo más pequeño debes redibujar la imagen en un
<canvas>y recodificarla concanvas.toBlob(callback, 'image/jpeg', 0.7). canvas.toBlob()es asíncrono y entrega el Blob a un callback en lugar de devolverlo, así que envuélvelo en una Promise para poder usarawaitsobre el resultado.- Sube ambos archivos en un único
FormData(elFileoriginal y elBlobde la miniatura con un nombre de archivo explícito), y luego recíbelos en el servidor conupload.fields()de multer. - Cada
URL.createObjectURL()debe emparejarse conURL.revokeObjectURL(), o el archivo subyacente permanecerá en memoria hasta que se descargue el documento.
Vista previa vs. miniatura real: ¿cuál necesitas?
Decide esto antes de escribir cualquier código, porque ambos caminos apenas comparten lógica. Una vista previa confirma visualmente que se seleccionó el archivo correcto. Una miniatura es un nuevo recurso de imagen más pequeño (menos bytes, dimensiones menores) que reduce el tiempo de subida y el procesamiento en el servidor, y puede almacenarse como imagen de cuadrícula sin reprocesar el original.
| Objetivo | Técnica | ¿Produce un archivo nuevo más pequeño? | Úsalo cuando |
|---|---|---|---|
| Mostrar la imagen elegida al instante | FileReader o URL.createObjectURL(file) | No | Solo necesitas confirmación visual |
| Reducir los bytes subidos / almacenar una variante pequeña | Canvas + toBlob() | Sí | Subes a un servidor o CDN |
| Arrastrar y soltar, progreso, múltiples tamaños, validación | Librería (FilePond) | Sí | Quieres todo incluido |
Una vista previa con readAsDataURL de FileReader no reduce nada en la transmisión. Para reducir realmente los bytes subidos debes redibujar la imagen en un <canvas> y recodificarla con canvas.toBlob().
¿Cómo se previsualiza una imagen con FileReader?
Discover how at OpenReplay.com.
Para una vista previa de solo visualización, escucha el evento change del input de archivo, lee el archivo con FileReader.readAsDataURL y asigna el data URL resultante a un <img> dentro de reader.onload. Es lo más rápido de implementar, y no redimensiona el archivo.
<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);
});
});
Fíjate en el patrón Array.from(files).forEach(...). El típico tutorial de jQuery para múltiples archivos reutiliza una única variable reader dentro de un bucle for, de modo que cada onload captura por clausura el último archivo: un bug de closure que muestra la misma imagen repetidamente. Cada archivo necesita su propio FileReader dentro de la iteración, algo que forEach te da gratis.
Una alternativa más ligera para archivos grandes es URL.createObjectURL(file), que devuelve una URL blob corta en lugar de una cadena base64 y evita el consumo excesivo de memoria de codificar el archivo completo en un data URL, a costa de tener que llamar obligatoriamente a revokeObjectURL() después.
¿Cómo se genera una miniatura redimensionada con Canvas?
Para producir un archivo realmente más pequeño, carga la imagen, escala sus dimensiones preservando la relación de aspecto, dibújala en un canvas y recodifícala. Para preservar la relación de aspecto, escala ambas dimensiones por el mismo factor (maxSize / ladoMásLargo) en lugar de fijar el ancho y el alto de forma independiente.
canvas.toBlob(callback, 'image/jpeg', 0.7) es asíncrono: entrega el Blob a su callback en lugar de devolverlo, así que envuélvelo en una Promise si quieres hacer await sobre la miniatura. Un canvas normal no tiene una variante que devuelva una promesa; solo OffscreenCanvas.convertToBlob() devuelve una Promise de forma nativa.
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);
}
}
El tercer argumento de toBlob define la calidad de codificación en una escala de 0 a 1. Solo los formatos con pérdida lo tienen en cuenta, así que modifica la salida para image/jpeg e image/webp y no hace nada para image/png. Un valor de 0,6–0,8 es el punto óptimo en la práctica. Recodificar una foto de varios megapíxeles a un JPEG de 200 px suele reducir su tamaño en uno o dos órdenes de magnitud, que es precisamente el objetivo de hacer esto en el cliente.
Mostrar la miniatura y subir ambos archivos
Muestra el Blob generado con URL.createObjectURL(blob) y luego envíalo al servidor junto con el original. Para la subida, añade ambos archivos a un único FormData: el File original y el Blob de la miniatura, pasando un nombre de archivo como tercer argumento. Envía eso por POST con fetch, y en el servidor upload.fields() de multer recibe los dos campos por separado.
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 });
}
En el backend, multer parsea el cuerpo multipart. Los nombres de campo en upload.fields() deben coincidir exactamente con las claves de FormData.append, o ese campo se descartará 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 }));
La rama 2.x de multer incorpora las correcciones de seguridad de las que carece la rama 1.x, y su package.json establece el mínimo en Node.js 10.16.0, no en Node 18. El mínimo de Node 18 llega con la rama 3.x, que todavía está en alfa. La versión estable más reciente en el changelog de multer es la 2.2.0, así que consúltalo antes de fijar una versión.
La opción de librería: FilePond y compañía
Cuando además quieres arrastrar y soltar, progreso de subida, validación y múltiples variantes redimensionadas, recurre a FilePond con sus plugins de imagen en lugar de construirlo todo a mano. El plugin filepond-plugin-image-preview renderiza la vista previa, filepond-plugin-image-resize escribe los metadatos de redimensionado y filepond-plugin-image-transform realiza el redimensionado real y te entrega los Blobs de salida.
FilePond.registerPlugin(
FilePondPluginImagePreview,
FilePondPluginImageResize,
FilePondPluginImageTransform
);
FilePond.create(document.querySelector('input[type="file"]'), {
imageResizeTargetWidth: 256,
imageResizeMode: 'contain',
});
Fija FilePond a la rama de la versión 4 (@^4), tal como aconseja la documentación de instalación de FilePond. La rama 4.x es la estable, en la 4.32.12 según el changelog, mientras que la v5 sigue en beta. Cargar unpkg.com/filepond sin fijar la versión sigue la etiqueta latest, así que hoy sirve la versión estable actual, pero te llevará a la siguiente versión mayor el día que esa rama sea promovida.
Específicamente para un reescalado de mayor calidad, Pica (10.0.2) aplica un filtro de remuestreo apropiado y puede ejecutarse en un Web Worker; selecciona su algoritmo mediante la opción filter, por ejemplo { filter: 'lanczos3' }. El paquete browser-image-compression (2.0.2) es otra opción, pero su última versión fue en marzo de 2023 y Snyk califica su mantenimiento como inactivo, así que valóralo antes de adoptarlo.
Detalles a tener en cuenta y buenas prácticas
Estos son los modos de fallo que las repeticiones de sesión de interfaces de subida sacan a la luz con frecuencia (crecimiento silencioso de memoria, miniaturas rotadas y pestañas que se cuelgan con archivos enormes):
- Revoca las object URLs. Llama siempre a
URL.revokeObjectURL()cuando elimines una vista previa: cadacreateObjectURL()mantiene el archivo subyacente en memoria hasta que la URL se revoca explícitamente o se descarga el documento. Las object URLs sin límite son una fuente clásica de crecimiento gradual de la memoria de la pestaña. - FileList es de solo lectura. Como un
FileListes de solo lectura, no puedes extraer un archivo de un<input>, así que gestiona tu propio array editable y reconstruye la subida a partir de él. - Valida antes de procesar. Combina
accept="image/*"con una comprobación en tiempo de ejecuciónfile.type.startsWith('image/')y un límite de tamaño.acceptes una sugerencia de UX, no una restricción real. - Limita las dimensiones para proteger la pestaña. Las imágenes muy grandes pueden agotar la memoria y hacer que la pestaña se cuelgue durante la decodificación; rechaza archivos por encima de un umbral de bytes y acota
maxSizeantes de dibujar. - Rotación EXIF. Redimensionar a través de un canvas puede descartar el flag de orientación EXIF, de modo que una foto vertical de móvil puede salir de lado. Prueba siempre con fotos verticales reales tomadas con un móvil.
- Calidad al reducir. Para reducciones grandes, un único
drawImage()puede verse tosco; redimensiona por pasos o usa una librería como Pica, que aplica un filtro de remuestreo apropiado para obtener miniaturas más nítidas. - El “tainting” del canvas solo afecta a imágenes cross-origin cargadas desde otros dominios; los archivos seleccionados por el usuario nunca contaminan el canvas, así que aquí no hace falta gestionar
crossOrigin. - Accesibilidad. Dale a cada
<img>de vista previa un textoaltsignificativo y etiqueta los botones de eliminar con ARIA para que la interfaz sea usable sin ver la miniatura.
Conclusión
Elige el camino que se ajuste a tu objetivo: una vista previa con FileReader u object URL cuando solo necesitas confirmación visual, y una miniatura con toBlob de Canvas cuando realmente necesitas menos bytes en la transmisión. Empieza con el helper makeThumbnail envuelto en una Promise que vimos arriba, sube el original y la miniatura juntos en un único FormData, y programa la limpieza con revokeObjectURL desde el primer commit en lugar de añadirla a posteriori cuando aparezca una fuga de memoria.
Preguntas frecuentes
¿Crear una vista previa reduce el tamaño del archivo que sube el usuario?
No. Una vista previa con readAsDataURL de FileReader o con URL.createObjectURL es solo para visualización y sube exactamente los bytes originales, sin ninguna reducción en la transmisión. Para reducir realmente el archivo subido debes redibujar la imagen en un canvas con dimensiones menores y recodificarla con canvas.toBlob, y luego subir ese Blob en lugar del original, o junto a él.
¿Por qué mi miniatura generada con canvas aparece rotada de forma incorrecta?
Redimensionar una imagen a través de un canvas puede descartar el flag de orientación EXIF que los móviles almacenan en las fotos verticales, de modo que un original correctamente orientado sale de lado en la miniatura. Los navegadores orientan automáticamente un elemento img normal, pero drawImage no siempre traslada esa orientación al canvas. Prueba siempre tu flujo de redimensionado con fotos verticales reales tomadas con un móvil, y no solo con imágenes de prueba en horizontal.
¿Cómo hago await sobre canvas.toBlob si solo acepta un callback?
Envuélvelo en una Promise, porque canvas.toBlob es asíncrono y entrega el Blob a su callback en lugar de devolverlo. Crea un helper que devuelva new Promise y llame a canvas.toBlob con resolve, rechazando cuando el callback reciba null. Un canvas normal no tiene una forma nativa que devuelva una promesa; solo OffscreenCanvas.convertToBlob devuelve una Promise directamente.
¿Por qué mi campo de miniatura nunca llega al backend con multer?
Los nombres de campo pasados a upload.fields de multer deben coincidir exactamente con las claves de FormData.append en el cliente, o ese campo se descarta silenciosamente y sin error. Si en el cliente añades 'thumbnails', tu servidor debe declarar name 'thumbnails' en upload.fields. Comprueba también tu versión de multer: la rama 2.x incorpora correcciones de seguridad de las que carece la rama 1.x.