Comment créer des vignettes d'images avant l'upload
Créez des miniatures avant l envoi avec aperçu FileReader, redimensionnement Canvas toBlob, upload FormData et champs multer.
Un aperçu affiche le fichier sélectionné par l’utilisateur à une taille réduite mais envoie exactement les mêmes octets ; une vignette est une image réellement ré-encodée, plus petite, que vous pouvez uploader en complément — ou à la place — de l’original.
Si vous avez déjà livré ce que vous pensiez être une fonctionnalité de vignettes, avant de voir une photo de téléphone de 12 Mo grimper malgré tout jusqu’au serveur, vous savez déjà que la distinction compte. La plupart des tutoriels brouillent cette différence et appellent « vignette » une simple balise <img> réduite, ce qui est faux : réduire une image à l’écran avec du CSS ne change rien sur le réseau. Cet article sépare les deux notions, puis progresse du plus simple aperçu avec FileReader jusqu’à une véritable vignette redimensionnée via Canvas et prête à être uploadée, sans oublier les pièges de nettoyage et de validation qui cassent les interfaces d’upload en production.
Points clés
- Un aperçu via
FileReader.readAsDataURLest purement visuel : il envoie les octets originaux à l’identique et ne réduit rien sur le réseau. - Pour produire un fichier plus petit, vous devez redessiner l’image dans un
<canvas>et la ré-encoder aveccanvas.toBlob(callback, 'image/jpeg', 0.7). canvas.toBlob()est asynchrone et transmet le Blob à un callback au lieu de le retourner ; encapsulez-le donc dans une Promise pour pouvoirawaitle résultat.- Envoyez les deux fichiers dans un même
FormData(leFileoriginal et leBlobde la vignette avec un nom de fichier explicite), puis récupérez-les côté serveur avecupload.fields()de multer. - Chaque
URL.createObjectURL()doit être associé à unURL.revokeObjectURL(), faute de quoi le fichier sous-jacent reste en mémoire jusqu’au déchargement du document.
Aperçu ou véritable vignette : de quoi avez-vous besoin ?
Tranchez cette question avant d’écrire la moindre ligne de code, car les deux approches ne partagent presque aucune logique. Un aperçu confirme visuellement que le bon fichier a été sélectionné. Une vignette est un nouvel asset image, plus petit (moins d’octets, dimensions réduites), qui diminue le temps d’upload et le traitement côté serveur, et qui peut être stocké comme image de grille sans retraiter l’original.
| Objectif | Technique | Produit un nouveau fichier plus petit ? | À utiliser quand |
|---|---|---|---|
| Afficher instantanément l’image sélectionnée | FileReader ou URL.createObjectURL(file) | Non | Vous n’avez besoin que d’une confirmation visuelle |
| Réduire les octets uploadés / stocker une variante légère | Canvas + toBlob() | Oui | Vous uploadez vers un serveur ou un CDN |
| Glisser-déposer, progression, tailles multiples, validation | Bibliothèque (FilePond) | Oui | Vous voulez une solution clé en main |
Un aperçu readAsDataURL avec FileReader ne réduit rien sur le réseau. Pour diminuer réellement les octets uploadés, vous devez redessiner l’image dans un <canvas> et la ré-encoder avec canvas.toBlob().
Comment prévisualiser une image avec FileReader ?
Discover how at OpenReplay.com.
Pour un aperçu purement visuel, écoutez l’événement change du champ de fichier, lisez le fichier avec FileReader.readAsDataURL, puis affectez l’URL de données obtenue à une balise <img> dans reader.onload. C’est la solution la plus rapide à livrer, et elle ne redimensionne pas le fichier.
<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);
});
});
Notez le motif Array.from(files).forEach(...). Le tutoriel jQuery classique sur les fichiers multiples réutilise une même variable reader à l’intérieur d’une boucle for, si bien que chaque onload capture le dernier fichier : un bug de closure qui affiche la même image en boucle. Chaque fichier a besoin de son propre FileReader au sein de l’itération, ce que forEach vous offre gratuitement.
Une alternative plus légère pour les gros fichiers est URL.createObjectURL(file), qui retourne une courte URL de blob plutôt qu’une chaîne base64 et évite la surcharge mémoire liée à l’encodage complet du fichier en URL de données, au prix d’un revokeObjectURL() obligatoire par la suite.
Comment générer une vignette redimensionnée avec Canvas ?
Pour produire un véritable fichier plus petit, chargez l’image, mettez ses dimensions à l’échelle en préservant le ratio d’aspect, dessinez-la dans un canvas, puis ré-encodez. Pour conserver le ratio d’aspect, appliquez le même facteur d’échelle aux deux dimensions (maxSize / longestSide) au lieu de définir largeur et hauteur indépendamment.
canvas.toBlob(callback, 'image/jpeg', 0.7) est asynchrone : la fonction transmet le Blob à son callback au lieu de le retourner ; encapsulez-la donc dans une Promise si vous souhaitez await la vignette. Un canvas classique ne propose aucune forme retournant une promesse ; seul OffscreenCanvas.convertToBlob() retourne nativement une Promise.
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);
}
}
Le troisième argument de toBlob définit la qualité d’encodage sur une échelle de 0 à 1. Seuls les formats avec perte en tiennent compte : il modifie donc la sortie pour image/jpeg et image/webp, et n’a aucun effet sur image/png. Une valeur de 0,6 à 0,8 constitue le compromis idéal en pratique. Ré-encoder une photo de plusieurs mégapixels en JPEG de 200 px divise généralement son poids par un à deux ordres de grandeur — c’est précisément l’intérêt de faire ce travail côté client.
Afficher la vignette et uploader les deux fichiers
Affichez le Blob généré avec URL.createObjectURL(blob), puis envoyez-le au serveur en même temps que l’original. Pour l’upload, ajoutez les deux fichiers à un même FormData : le File original et le Blob de la vignette, en passant un nom de fichier comme troisième argument. Envoyez le tout en POST avec fetch ; côté serveur, upload.fields() de multer récupère les deux champs séparément.
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 });
}
Côté backend, multer analyse le corps multipart. Les noms de champs déclarés dans upload.fields() doivent correspondre exactement aux clés de FormData.append, sans quoi le champ concerné est silencieusement ignoré :
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 branche 2.x de multer intègre les correctifs de sécurité absents de la branche 1.x, et son package.json fixe le plancher à Node.js 10.16.0, et non Node 18. Le minimum Node 18 arrive avec la branche 3.x, encore en alpha. La version stable la plus récente listée dans le changelog de multer est la 2.2.0 : vérifiez-y l’état des lieux avant de figer une version.
L’option bibliothèque : FilePond et consorts
Lorsque vous souhaitez également le glisser-déposer, la progression d’upload, la validation et plusieurs variantes redimensionnées, tournez-vous vers FilePond et ses plugins image plutôt que de tout coder à la main. Le plugin filepond-plugin-image-preview affiche l’aperçu, filepond-plugin-image-resize écrit les métadonnées de redimensionnement, et filepond-plugin-image-transform effectue le redimensionnement réel et vous restitue les Blobs de sortie.
FilePond.registerPlugin(
FilePondPluginImagePreview,
FilePondPluginImageResize,
FilePondPluginImageTransform
);
FilePond.create(document.querySelector('input[type="file"]'), {
imageResizeTargetWidth: 256,
imageResizeMode: 'contain',
});
Figez FilePond sur la branche 4 (@^4), comme le recommande la documentation d’installation de FilePond. La branche 4.x est la version stable, en 4.32.12 au changelog, tandis que la v5 est encore en bêta. Charger unpkg.com/filepond sans version épinglée suit le tag latest : cela vous sert aujourd’hui la version stable courante, mais vous basculera sur la prochaine version majeure dès que celle-ci sera promue.
Pour une réduction d’échelle de meilleure qualité en particulier, Pica (10.0.2) applique un véritable filtre de rééchantillonnage et peut s’exécuter dans un Web Worker ; sélectionnez son algorithme via l’option filter, par exemple { filter: 'lanczos3' }. Le paquet browser-image-compression (2.0.2) est une autre option, mais sa dernière publication remonte à mars 2023 et Snyk juge sa maintenance inactive : pesez ce point avant de l’adopter.
Pièges et bonnes pratiques
Voici les modes de défaillance que les rejeux de session sur les interfaces d’upload font fréquemment remonter (croissance mémoire silencieuse, vignettes pivotées et onglets qui se figent sur les fichiers volumineux) :
- Révoquez les object URLs. Appelez toujours
URL.revokeObjectURL()lorsque vous supprimez un aperçu : chaquecreateObjectURL()maintient le fichier sous-jacent en mémoire jusqu’à ce que l’URL soit explicitement révoquée ou que le document soit déchargé. Les object URLs non bornées sont une source classique de croissance progressive de la mémoire d’un onglet. - FileList est en lecture seule. Puisqu’une
FileListest en lecture seule, vous ne pouvez pas en retirer un fichier depuis un<input>: maintenez plutôt votre propre tableau modifiable et reconstruisez l’upload à partir de celui-ci. - Validez avant de traiter. Combinez
accept="image/*"avec une vérification à l’exécutionfile.type.startsWith('image/')et un plafond de taille.acceptest une indication d’UX, pas une contrainte appliquée. - Plafonnez les dimensions pour protéger l’onglet. Les images très volumineuses peuvent épuiser la mémoire et faire planter l’onglet pendant le décodage ; rejetez les fichiers dépassant un seuil en octets et bornez
maxSizeavant de dessiner. - Rotation EXIF. Le redimensionnement via un canvas peut faire perdre le drapeau d’orientation EXIF : une photo de téléphone en mode portrait peut donc ressortir couchée. Testez toujours avec de vraies photos portrait prises au téléphone.
- Qualité de réduction. Pour les fortes réductions d’échelle, un unique
drawImage()peut donner un rendu grossier ; redimensionnez par étapes ou utilisez une bibliothèque comme Pica, qui applique un véritable filtre de rééchantillonnage pour des vignettes plus nettes. - La contamination du canvas (canvas tainting) ne concerne que les images cross-origin chargées depuis d’autres domaines ; les fichiers sélectionnés par l’utilisateur ne contaminent jamais le canvas, aucune gestion de
crossOriginn’est donc nécessaire ici. - Accessibilité. Donnez à chaque
<img>d’aperçu un textealtpertinent et étiquetez les boutons de suppression avec ARIA, afin que l’interface reste utilisable sans voir la vignette.
Pour conclure
Choisissez la voie qui correspond à votre objectif : un aperçu via FileReader ou object URL lorsque vous n’avez besoin que d’une confirmation visuelle, et une vignette Canvas via toBlob lorsque vous avez réellement besoin de réduire les octets transmis. Partez du helper makeThumbnail encapsulé dans une Promise présenté plus haut, uploadez l’original et la vignette ensemble dans un même FormData, et mettez en place le nettoyage par revokeObjectURL dès le premier commit plutôt que de le greffer après coup, une fois la fuite mémoire constatée.
FAQ
La création d'un aperçu réduit-elle la taille du fichier uploadé par l'utilisateur ?
Non. Un aperçu via FileReader readAsDataURL ou via URL.createObjectURL est purement visuel et envoie les octets originaux à l'identique, sans aucune réduction sur le réseau. Pour réellement alléger le fichier uploadé, vous devez redessiner l'image dans un canvas à des dimensions plus petites et la ré-encoder avec canvas.toBlob, puis uploader ce Blob à la place de l'original, ou en complément.
Pourquoi ma vignette générée par canvas est-elle pivotée dans le mauvais sens ?
Le redimensionnement d'une image via un canvas peut faire perdre le drapeau d'orientation EXIF que les téléphones enregistrent sur les photos portrait : un original correctement orienté ressort alors couché dans la vignette. Les navigateurs orientent automatiquement un simple élément img, mais drawImage ne reporte pas toujours cette orientation sur le canvas. Testez toujours votre chaîne de redimensionnement avec de vraies photos portrait prises au téléphone, et pas uniquement avec des images de test en mode paysage.
Comment utiliser await avec canvas.toBlob alors qu'il n'accepte qu'un callback ?
Encapsulez-le dans une Promise, car canvas.toBlob est asynchrone et transmet le Blob à son callback au lieu de le retourner. Créez un helper qui retourne new Promise et appelle canvas.toBlob avec resolve, en rejetant lorsque le callback reçoit null. Un canvas classique ne propose aucune forme native retournant une promesse ; seul OffscreenCanvas.convertToBlob retourne directement une Promise.
Pourquoi mon champ de vignette n'arrive-t-il jamais sur le backend multer ?
Les noms de champs passés à upload.fields de multer doivent correspondre exactement aux clés de FormData.append côté client, sinon le champ concerné est silencieusement ignoré, sans aucune erreur. Si vous ajoutez 'thumbnails' côté client, votre serveur doit déclarer le nom 'thumbnails' dans upload.fields. Vérifiez également votre version de multer : la branche 2.x intègre des correctifs de sécurité absents de la branche 1.x.