Back

Comment créer un fichier téléchargeable dans le navigateur

Comment créer un fichier téléchargeable dans le navigateur

La création d’un fichier téléchargeable dans le navigateur combine quatre API : un Blob pour les données, URL.createObjectURL() pour une référence en mémoire, une ancre avec l’attribut download pour déclencher la sauvegarde, et URL.revokeObjectURL() pour libérer la référence ensuite. Le schéma complet tient en moins de dix lignes :

function downloadBlob(data, filename, type) {
  const blob = new Blob([data], { type });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

downloadBlob(JSON.stringify({ ok: true }), 'config.json', 'application/json');

Il s’agit du schéma canonique, qui couvre le cas courant de la sauvegarde d’un CSV généré, d’une configuration JSON ou d’une image. Mais le chemin nominal dissimule de véritables points de défaillance : l’attribut download qui se désactive silencieusement sur les URL cross-origin, iOS Safari qui ouvre les téléchargements dans un nouvel onglet, les object URLs qui fuient entre les rendus de composants, et Excel qui déforme les CSV en UTF-8. Cet article couvre le schéma fonctionnel, la File System Access API moderne, le streaming pour les fichiers trop volumineux pour tenir en mémoire, ainsi que les particularités de chaque plateforme qui font échouer les téléchargements en production.

Points clés

  • Le téléchargement côté client canonique suit le schéma : new Blob([data], { type })URL.createObjectURL() → une ancre avec l’attribut downloadclick()URL.revokeObjectURL().
  • La durée de vie d’une object URL est liée au document qui l’a créée ; révoquez-la donc dans une fonction de nettoyage du framework (useEffect return, Vue onUnmounted) plutôt que de vous fier à une relation temporelle non vérifiée avec a.click().
  • showSaveFilePicker() est le seul moyen natif dans le navigateur de permettre à l’utilisateur de choisir un emplacement de sauvegarde, mais cette API est expérimentale et réservée à Chromium ; détectez sa présence et prévoyez un repli sur le schéma avec ancre.
  • Excel n’ouvre correctement un CSV en UTF-8 que si le fichier contient un BOM ; ajoutez donc \uFEFF en préfixe de la chaîne.
  • iOS Safari a historiquement offert un support peu fiable pour les téléchargements via l’attribut download, ce qui explique pourquoi les téléchargements s’ouvrent souvent dans un nouvel onglet au lieu d’être sauvegardés.

Le schéma canonique : télécharger un fichier avec JavaScript dans le navigateur

La méthode fiable pour télécharger un fichier généré avec JavaScript consiste à construire un Blob, à créer une object URL avec URL.createObjectURL(), à l’assigner au href d’une ancre, à définir l’attribut download avec le nom du fichier, à cliquer sur l’ancre par programmation, puis à libérer l’URL avec URL.revokeObjectURL(). Le constructeur Blob vous permet de définir le type MIME indépendamment des données, et l’object URL est une référence courte (blob:https://…) vers laquelle l’ancre peut naviguer.

Évitez les data URIs par défaut. Les data URIs constituent un mauvais choix par défaut pour la génération de fichiers côté client : le Base64 encode chaque groupe de 3 octets en 4 caractères, augmentant la taille de la charge utile d’environ un tiers avant le rembourrage (selon la RFC 4648), et la chaîne encodée dans son intégralité doit tenir en mémoire en tant que valeur d’attribut DOM. Les limites de taille actuelles des URL data: sont de 512 Mo dans Chromium et Firefox, et de 2 048 Mo dans Safari/WebKit (référence MDN sur les URL data:) — mais le surcoût d’encodage et le coût en mémoire de la chaîne font du Blob le meilleur choix par défaut, bien avant d’atteindre ces plafonds.

L’ancre n’a pas besoin d’être attachée au DOM pour que click() fonctionne dans les navigateurs actuels, ce qui rend l’utilitaire autonome :

// Fonctionne dans Chrome 14+, Firefox 20+ (attribut download, HTML5).
// caniuse: https://caniuse.com/download
function downloadBlob(data, filename, type = 'application/octet-stream') {
  const blob = new Blob([data], { type });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a); // ajouté pour une compatibilité maximale
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}

const csv = 'name,role\nAda,Engineer\nGrace,Architect';
downloadBlob(csv, 'team.csv', 'text/csv;charset=utf-8;');

L’appel à URL.revokeObjectURL(url) est plus important que la plupart des exemples ne le laissent entendre. La durée de vie d’une object URL est liée au document qui l’a créée ; elle persiste donc en mémoire jusqu’à ce que vous la révoquez ou que le document se décharge. Dans un script jetable, cela est sans conséquence ; mais dans une application à base de composants où l’utilitaire s’exécute à chaque clic sur un bouton, les URL non libérées s’accumulent. La révocation synchrone ci-dessus est sûre ici, car rien dans cette fonction ne survit à l’appel — mais comme le montre la section sur les frameworks, cet emplacement est incorrect à l’intérieur d’un composant.

L’attribut HTML download et son échec silencieux en cross-origin

Pour un fichier déjà hébergé sur votre propre origine, vous n’avez pas besoin de JavaScript — ajoutez simplement l’attribut download à une ancre ordinaire :

<a href="/reports/q3.pdf" download="q3-report.pdf">Télécharger le rapport</a>

L’attribut download indique au navigateur de sauvegarder la ressource liée au lieu d’y naviguer. Il a été introduit dans HTML5 et est pris en charge dans Chrome 14+ et Firefox 20+ (caniuse : attribut download). Il accepte également les URL blob: et data:, ce qui explique son association avec le schéma canonique présenté plus haut.

Le point de défaillance concerne les URL cross-origin. Pour les téléchargements cross-origin, l’attribut download n’est respecté que si la réponse inclut également l’en-tête Content-Disposition: attachment ; sans lui, le navigateur ignore l’attribut et le lien ne constitue pas un téléchargement forcé fiable (selon la spécification HTML WHATWG, téléchargement de ressources). C’est une source fréquente de confusion : le même balisage qui télécharge un fichier de même origine naviguera vers un fichier servi depuis un CDN sur une origine différente, ou l’affichera directement. Si vous contrôlez le serveur, définissez l’en-tête à ce niveau. Sinon, récupérez le fichier et re-servez-le comme un blob depuis votre propre origine :

// Re-sert un fichier cross-origin comme un blob de même origine pour que `download` soit respecté.
async function downloadCrossOrigin(remoteUrl, filename) {
  const res = await fetch(remoteUrl); // nécessite que CORS autorise la requête
  const blob = await res.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

File System Access API : laisser l’utilisateur choisir l’emplacement de sauvegarde

La méthode showSaveFilePicker() de la File System Access API est le seul moyen natif dans le navigateur de permettre à l’utilisateur de choisir un emplacement et un nom de fichier avant l’écriture. Elle ouvre la boîte de dialogue de sauvegarde du système d’exploitation, retourne un handle de fichier et permet d’écrire via un FileSystemWritableFileStream. MDN la signale comme ayant une « disponibilité limitée » et étant « expérimentale » ; elle est disponible dans les navigateurs de la famille Chromium depuis la version 86 (Chrome Status) et n’est pas prise en charge dans Firefox ni Safari, ce qui rend obligatoire la détection de fonctionnalité avec repli sur le schéma avec ancre pour un usage en production.

showSaveFilePicker() lève une DOMException avec le nom AbortError si l’utilisateur ferme la boîte de dialogue ; gérez ce cas explicitement plutôt que de le traiter comme une erreur :

async function saveFile(data, filename, type = 'application/octet-stream') {
  // Détection de fonctionnalité. Remarque : la propriété peut exister mais lever une exception
  // dans certains contextes d'iframe sandbox, d'où la garde sur l'appel lui-même.
  if ('showSaveFilePicker' in window) {
    try {
      const handle = await window.showSaveFilePicker({
        suggestedName: filename,
        types: [{ description: 'File', accept: { [type]: ['.' + filename.split('.').pop()] } }],
      });
      const writable = await handle.createWritable();
      await writable.write(new Blob([data], { type }));
      await writable.close();
      return;
    } catch (err) {
      if (err.name === 'AbortError') return; // annulation par l'utilisateur, pas une erreur
      // repli sur l'ancre en cas d'autre échec
    }
  }
  // Repli : ancre + object URL (sans choix d'emplacement ; répertoire par défaut du navigateur).
  downloadBlob(data, filename, type);
}

La vérification 'showSaveFilePicker' in window est la bonne approche, mais elle n’est pas infaillible : dans certains contextes d’iframe sandbox, la propriété existe mais lève une exception à l’appel, ce qui explique pourquoi le try/catch entoure l’invocation réelle et non la seule détection.

Streaming de fichiers volumineux dépassant la mémoire disponible

Le streaming contourne le plafond mémoire du Blob en écrivant les fragments directement sur le disque au lieu de mettre l’intégralité de la charge utile en mémoire vive. Il n’existe pas de seuil fixe en octets pour ce plafond — il dépend de l’appareil, du système d’exploitation et du tas disponible dans le navigateur. Deux approches de streaming permettent d’éviter de mettre le fichier entier en mémoire tampon.

Dans Chromium, écrivez de manière incrémentielle via le FileSystemWritableFileStream retourné par showSaveFilePicker(). Chaque appel à write() ajoute un fragment au flux inscriptible, de sorte que l’application n’a pas besoin de conserver l’intégralité du fichier en mémoire à un instant donné :

// Chromium 86+ uniquement. Écrit par fragments ; ne conserve jamais le fichier complet en mémoire.
async function streamToDisk(filename, chunks) {
  const handle = await window.showSaveFilePicker({ suggestedName: filename });
  const writable = await handle.createWritable();
  for await (const chunk of chunks) {
    await writable.write(chunk); // chunk : string | ArrayBuffer | Blob
  }
  await writable.close();
}

Pour les navigateurs sans File System Access API, StreamSaver.js est la solution multiplateforme. Selon son README, StreamSaver.js sauvegarde les fichiers volumineux générés côté client en créant un flux inscriptible et en émulant un téléchargement piloté par le serveur à l’aide d’en-têtes de réponse et d’un service worker, de sorte que les données sont écrites sur le disque au fil du streaming plutôt qu’assemblées en un seul Blob. Il s’appuie sur le standard WHATWG Streams :

import streamSaver from 'streamsaver';

// Émule un téléchargement serveur via un service worker ; les données sont streamées vers le disque.
function streamWithStreamSaver(filename, readableStream) {
  const fileStream = streamSaver.createWriteStream(filename);
  // readableStream : un ReadableStream WHATWG de fragments Uint8Array
  return readableStream.pipeTo(fileStream);
}

Recourez au streaming lorsque la sortie est véritablement volumineuse — exports de plusieurs centaines de mégaoctets, vidéo générée ou flux de données de longue durée. Pour un CSV de quelques milliers de lignes, le schéma canonique avec Blob est plus simple et tout à fait adapté.

Particularités d’iOS Safari et comment les contourner

iOS Safari a historiquement offert un support peu fiable pour les fichiers générés et téléchargés via l’attribut download (bug WebKit 167341), si bien qu’un fichier généré s’ouvre fréquemment dans un nouvel onglet ou s’affiche en ligne au lieu d’être sauvegardé — faisant d’iOS la plateforme où le code de téléchargement échoue le plus souvent en production. Le README de FileSaver.js documente la conséquence pratique : sur iOS, saveAs() doit être exécuté dans le contexte d’une interaction utilisateur telle qu’un onClick, et un setTimeout empêchera son déclenchement ; en raison des restrictions d’iOS, saveAs() peut ouvrir une nouvelle fenêtre au lieu de télécharger (README FileSaver.js).

Les solutions concrètes :

  • Gardez le téléchargement synchrone avec le geste utilisateur. Déclenchez le clic directement dans le gestionnaire d’événements, et non après un await ou un setTimeout qui le désolidarise du geste. Si vous devez effectuer un travail asynchrone au préalable, récupérez et préparez les données avant que l’utilisateur clique, puis déclenchez la sauvegarde de manière synchrone.
  • Proposez à l’utilisateur un repli explicite. Lorsqu’un nouvel onglet s’ouvre avec le fichier affiché en ligne, affichez une indication du type « appuyez sur Partager → Enregistrer dans Fichiers », car l’utilisateur doit effectuer la sauvegarde manuellement.
  • Préférez text/plain ou un type affichable pour les exports texte sur iOS, en acceptant que le fichier puisse s’ouvrir dans un visualiseur que l’utilisateur sauvegarde ensuite, plutôt que de tenter un téléchargement forcé que la plateforme ne garantit pas de manière fiable.

Ces échecs sont invisibles dans les journaux serveur et les outils d’analyse, car aucune requête n’atteint votre backend et aucune exception n’est levée. Les replays de session en production sur des flux de téléchargement montrent fréquemment des utilisateurs qui cliquent plusieurs fois sur le déclencheur faute de retour visuel — sur iOS, cela se traduit souvent par l’ouverture d’un nouvel onglet que l’utilisateur ferme immédiatement, avant de revenir perplexe. Le replay de session est l’une des rares techniques permettant de mettre en évidence cette catégorie d’échecs silencieux, liés aux gestes et aux spécificités de la plateforme.

// Adapté à iOS : effectuez le travail asynchrone d'abord, puis sauvegardez de manière synchrone dans le geste.
async function prepareThenDownload(button, getData, filename, type) {
  const data = await getData(); // le travail réseau/CPU se produit avant le chemin de clic
  button.onclick = () => {
    // synchrone avec le geste utilisateur — ni setTimeout, ni await ici
    downloadBlob(data, filename, type);
  };
}

Pièges liés aux types MIME : affichage en ligne, CSV Excel et fins de ligne

Le type MIME que vous passez au constructeur Blob aide le navigateur à déterminer comment interpréter le fichier ; une erreur à ce niveau est une cause fréquente d’ouverture inattendue du contenu téléchargé ou d’un traitement incorrect. Définissez un type correspondant aux données et associez-le à l’attribut download pour que le navigateur traite le résultat comme une pièce jointe.

Le bug CSV le plus signalé est l’affichage de caractères accentués sous forme de caractères parasites à l’ouverture dans Excel. La solution est un marqueur d’ordre d’octet (BOM). Excel ouvre correctement un fichier CSV en UTF-8 lorsque le fichier est sauvegardé avec un BOM ; ajoutez-en donc un en préfixe de la chaîne (Support Microsoft : ouverture de fichiers CSV UTF-8 dans Excel) :

// Sans BOM : Excel peut afficher des caractères parasites pour les textes accentués (é, ü, ñ).
// Avec BOM : Excel ouvre correctement le fichier UTF-8.
function downloadCSV(csvString, filename = 'export.csv') {  
  downloadBlob('\uFEFF' + csvString, filename, 'text/csv;charset=utf-8;');  
}

Deux précisions supplémentaires issues de la RFC 4180, qui enregistre text/csv comme type MIME pour les CSV : la spécification définit CRLF (\r\n) comme délimiteur d’enregistrement ; préférez donc \r\n à \n entre les lignes pour une compatibilité maximale avec les tableurs, et les champs contenant des virgules, des guillemets ou des sauts de ligne doivent être encadrés de guillemets doubles, les guillemets internes étant doublés.

Évitez d’utiliser application/octet-stream comme mécanisme universel de téléchargement forcé. Il s’agit du type enregistré par l’IANA pour les données binaires arbitraires, mais ce n’est pas un mécanisme de téléchargement forcé fiable — le README de FileSaver.js note que l’utilisation d’application/octet-stream pour forcer les téléchargements peut causer des problèmes dans Safari. Utilisez le type correct et spécifique, et fiez-vous à l’attribut download (ou à Content-Disposition) pour imposer la sauvegarde.

Pièges liés aux frameworks : SSR, cycle de vie des object URLs et fuites mémoire

Deux bugs liés au cycle de vie des composants font échouer le schéma de téléchargement canonique dans React, Vue et Svelte : l’appel d’API DOM lors du rendu côté serveur (SSR, où document est indéfini), et la révocation des object URLs au mauvais moment dans le cycle de vie du composant. Ces deux problèmes ont la même cause racine — les composants sont rendus sur le serveur et re-rendus côté client, mais le schéma natif suppose un seul document et une seule exécution.

Protégez-vous contre le SSR. Dans Next.js, Nuxt ou SvelteKit, le code d’un composant peut s’exécuter dans un contexte où document est indéfini ; appeler document.createElement dans ce cas lève une exception. Conditionnez tout utilitaire de téléchargement à une vérification à l’exécution :

function triggerDownload(data, filename, type) {
  if (typeof document === 'undefined') return; // garde SSR
  downloadBlob(data, filename, type);
}

Révoquez les object URLs dans le nettoyage, pas immédiatement. Le URL.revokeObjectURL(url) synchrone du schéma canonique convient pour un utilitaire ponctuel qui se termine avant toute autre exécution. Mais si vous stockez une blob URL dans l’état pour l’utiliser comme href ou src, la révoquer trop tôt interrompt le téléchargement, et ne jamais la révoquer entraîne des fuites mémoire entre les re-rendus. La règle vérifiée : la durée de vie d’une object URL est liée au document qui l’a créée ; révoquez-la dès qu’elle n’est plus nécessaire. Dans les frameworks à base de composants, stockez la blob URL dans l’état et révoquez-la dans une fonction de nettoyage plutôt que de vous fier à une relation temporelle non vérifiée avec a.click().

import { useEffect, useState } from 'react';

function DownloadLink({ data, filename, type = 'application/json' }) {
  const [url, setUrl] = useState(null);

  useEffect(() => {
    const blob = new Blob([data], { type });
    const objectUrl = URL.createObjectURL(blob);
    setUrl(objectUrl);
    // Le nettoyage s'exécute au démontage et avant la prochaine exécution de l'effet :
    return () => URL.revokeObjectURL(objectUrl);
  }, [data, type]);

  // ANTI-PATTERN — ne PAS révoquer ici :
  //   <a href={url} download={filename} onClick={() => URL.revokeObjectURL(url)}>
  // Révoquer dans le clic peut libérer l'URL avant que le téléchargement ne démarre.

  return url ? <a href={url} download={filename}>Télécharger</a> : null;
}

L’équivalent Vue crée l’URL dans onMounted (ou un watch) et la révoque dans onUnmounted ; Svelte utilise onDestroy. Dans les trois cas, la fuite qui survient est une nouvelle object URL créée à chaque re-rendu ou à chaque clic sur un bouton sans révocation correspondante — la mémoire croît pendant toute la durée de vie du document.

Pour les navigateurs anciens qui ne prennent pas en charge l’attribut download, FileSaver.js reste un polyfill en une ligne — mais sur les cibles modernes, le schéma natif présenté ci-dessus le remplace avantageusement.

Conclusion

La séquence Blob → object URL → ancre → download → révocation couvre la grande majorité des besoins de téléchargement côté client, avec showSaveFilePicker() comme amélioration réservée à Chromium lorsque les utilisateurs doivent choisir un emplacement, et le streaming comme solution de secours lorsque les fichiers dépassent la capacité mémoire. Les bugs qui atteignent la production se situent rarement sur le chemin nominal — ce sont l’attribut cross-origin qui navigue silencieusement, le CSV qui nécessite un BOM, l’onglet iOS qui s’ouvre au lieu de sauvegarder, et l’object URL révoquée au mauvais moment. Intégrez le nettoyage dans le cycle de vie de vos composants, détectez la présence des API expérimentales avant de les appeler, et testez le flux sur un vrai appareil iOS avant de le mettre en production.

FAQ

Un Blob stocke les données brutes et est référencé par une object URL courte créée avec URL.createObjectURL(), tandis qu'une data URI intègre l'intégralité de la charge utile sous forme de chaîne Base64 directement dans l'URL. L'encodage Base64 augmente la taille de la charge utile d'environ un tiers avant le rembourrage, et la chaîne encodée dans son intégralité doit tenir en mémoire en tant que valeur d'attribut. Le Blob vous permet de définir un type MIME indépendamment des données et évite ces deux inconvénients, ce qui en fait le meilleur choix par défaut pour la génération de fichiers côté client.

L'attribut download est respecté pour les URL de même origine, mais ignoré pour les URL cross-origin, à moins que la réponse ne contienne également un en-tête Content-Disposition: attachment, conformément à la spécification HTML WHATWG. Un fichier servi depuis un CDN sur une origine différente sera affiché en ligne ou provoquera une navigation au lieu d'être téléchargé. La solution consiste à définir l'en-tête sur le serveur que vous contrôlez, ou à récupérer le fichier cross-origin et à le re-servir comme un blob de même origine via URL.createObjectURL().

Non. La méthode showSaveFilePicker() de la File System Access API n'est prise en charge que dans les navigateurs de la famille Chromium à partir de la version 86 et n'est pas implémentée dans Firefox ni Safari. MDN la signale comme ayant une 'disponibilité limitée' et étant 'expérimentale'. Pour cette raison, le code en production doit détecter la fonctionnalité avec 'showSaveFilePicker' in window et prévoir un repli sur le schéma avec ancre et object URL. Encadrez l'appel lui-même dans un try/catch, car la propriété peut exister mais lever une exception dans certains contextes d'iframe sandbox, et elle lève une AbortError lorsque l'utilisateur ferme la boîte de dialogue.

Révoquer l'object URL avant que le téléchargement ne démarre l'interrompt, car le navigateur ne peut plus résoudre la référence blob vers laquelle pointe l'ancre. Dans un utilitaire ponctuel qui se termine de manière synchrone, révoquer juste après click() est sûr. Dans un composant qui stocke la blob URL dans l'état pour un href ou un src, révoquez-la dans une fonction de nettoyage — par exemple la valeur de retour de useEffect, onUnmounted dans Vue, ou onDestroy dans Svelte. Ne jamais la révoquer entraîne à l'inverse des fuites mémoire entre les re-rendus pendant toute la durée de vie du document.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay