Lire et écrire des fichiers et des répertoires avec la bibliothèquebrowser-fs-access

Les navigateurs sont capables de traiter des fichiers et des répertoires depuis longtemps. L'API File fournit des fonctionnalités permettant de représenter des objets de fichier dans des applications Web, ainsi que de les sélectionner par programmation et d'accéder à leurs données. Mais en y regardant de plus près, il apparaît que tout ce qui brille n'est pas de l'or.

Méthode traditionnelle de gestion des fichiers

Ouverture des fichiers

En tant que développeur, vous pouvez ouvrir et lire des fichiers via l'élément <input type="file">. Dans sa forme la plus simple, l'ouverture d'un fichier peut ressembler à l'exemple de code ci-dessous. L'objet input vous donne un FileList, qui, dans le cas ci-dessous, ne se compose que d'un seul File. Un File est un type spécifique de Blob et peut être utilisé dans n'importe quel contexte où un Blob peut l'être.

const openFile = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

Ouvrir des répertoires

Pour ouvrir des dossiers (ou des répertoires), vous pouvez définir l'attribut <input webkitdirectory>. En dehors de cela, tout fonctionne comme ci-dessus. Malgré son nom précédé d'un préfixe du fournisseur, webkitdirectory n'est pas seulement utilisable dans les navigateurs Chromium et WebKit, mais également dans l'ancien Edge basé sur EdgeHTML, ainsi que dans Firefox.

Enregistrement (ou téléchargement) de fichiers

Pour enregistrer un fichier, vous êtes traditionnellement limité au téléchargement d'un fichier, qui fonctionne grâce à l'attribut <a download>. Étant donné un blob, vous pouvez définir l'attribut href de l'ancre sur une URL blob: que vous pouvez obtenir à partir de la méthode URL.createObjectURL().

const saveFile = async (blob) => {
  const a = document.createElement('a');
  a.download = 'my-file.txt';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

Problème

L'approche de téléchargement présente un inconvénient majeur : il est impossible de suivre un flux classique d'ouverture → modification → enregistrement, c'est-à-dire qu'il est impossible d'écraser le fichier d'origine. Vous obtenez plutôt une nouvelle copie du fichier d'origine dans le dossier de téléchargements par défaut du système d'exploitation chaque fois que vous "enregistrez".

API File System Access

L'API File System Access simplifie considérablement les opérations, l'ouverture et l'enregistrement. Il permet également un enregistrement réel, c'est-à-dire que vous pouvez non seulement choisir l'emplacement d'enregistrement d'un fichier, mais aussi écraser un fichier existant.

Ouverture des fichiers

Avec l'API File System Access, l'ouverture d'un fichier ne nécessite qu'un simple appel à la méthode window.showOpenFilePicker(). Cet appel renvoie un descripteur de fichier, à partir duquel vous pouvez obtenir le File réel via la méthode getFile().

const openFile = async () => {
  try {
    // Always returns an array.
    const [handle] = await window.showOpenFilePicker();
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Ouvrir des répertoires

Ouvrez un répertoire en appelant window.showDirectoryPicker(), ce qui permet de sélectionner les répertoires dans la boîte de dialogue de fichier.

Enregistrer des fichiers

L'enregistrement de fichiers est tout aussi simple. À partir d'un descripteur de fichier, vous créez un flux en écriture via createWritable(), puis vous écrivez les données du blob en appelant la méthode write() du flux, et enfin vous fermez le flux en appelant sa méthode close().

const saveFile = async (blob) => {
  try {
    const handle = await window.showSaveFilePicker({
      types: [{
        accept: {
          // Omitted
        },
      }],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
    return handle;
  } catch (err) {
    console.error(err.name, err.message);
  }
};

Présentation de browser-fs-access

Bien que l'API File System Access soit parfaitement adaptée, elle n'est pas encore largement disponible.

Tableau de compatibilité des navigateurs avec l&#39;API File System Access. Tous les navigateurs sont marqués comme &quot;non compatibles&quot; ou &quot;en attente d&#39;un flag&quot;.
Tableau des navigateurs compatibles avec l'API File System Access. (source)

C'est pourquoi je considère l'API File System Access comme une amélioration progressive. Je souhaite donc l'utiliser lorsque le navigateur est compatible avec celui-ci, et utiliser l'approche traditionnelle dans le cas contraire, tout en évitant de pénaliser l'utilisateur avec des téléchargements inutiles de code JavaScript non compatible. La bibliothèque browser-fs-access est la solution que je peux apporter à ce défi.

Philosophie de conception

Étant donné que l'API File System Access est susceptible d'être modifiée à l'avenir, l'API browser-fs-access n'est pas modélisée d'après elle. Autrement dit, la bibliothèque n'est pas un polyfill, mais un ponyfill. Vous pouvez importer (de manière statique ou dynamique) uniquement les fonctionnalités dont vous avez besoin pour que votre application soit aussi petite que possible. Les méthodes disponibles sont fileOpen(), directoryOpen() et fileSave(). En interne, la bibliothèque détecte si l'API File System Access est compatible, puis importe le chemin de code correspondant.

Utiliser la bibliothèque browser-fs-access

Les trois méthodes sont intuitives. Vous pouvez spécifier le mimeTypes ou le extensions de fichier accepté par votre application, et définir un indicateur multiple pour autoriser ou non la sélection de plusieurs fichiers ou répertoires. Pour en savoir plus, consultez la documentation de l'API browser-fs-access. L'exemple de code ci-dessous montre comment ouvrir et enregistrer des fichiers image.

// The imported methods will use the File
// System Access API or a fallback implementation.
import {
  fileOpen,
  directoryOpen,
  fileSave,
} from 'https://unpkg.com/browser-fs-access';

(async () => {
  // Open an image file.
  const blob = await fileOpen({
    mimeTypes: ['image/*'],
  });

  // Open multiple image files.
  const blobs = await fileOpen({
    mimeTypes: ['image/*'],
    multiple: true,
  });

  // Open all files in a directory,
  // recursively including subdirectories.
  const blobsInDirectory = await directoryOpen({
    recursive: true
  });

  // Save a file.
  await fileSave(blob, {
    fileName: 'Untitled.png',
  });
})();

Démo

Vous pouvez voir le code ci-dessus en action dans une démonstration sur Glitch. Son code source est également disponible sur cette page. Étant donné que, pour des raisons de sécurité, les sous-frames multi-origines ne sont pas autorisés à afficher un sélecteur de fichier, la démonstration ne peut pas être intégrée dans cet article.

La bibliothèque browser-fs-access dans la nature

Pendant mon temps libre, je contribue un peu à une PWA installable appelée Excalidraw, un outil pour tableau blanc qui vous permet d'esquisser facilement des diagrammes de manière à avoir l'impression qu'ils sont dessinés à la main. Il est entièrement responsif et fonctionne bien sur une grande variété d'appareils, des petits téléphones mobiles aux ordinateurs à grand écran. Cela signifie qu'il doit gérer les fichiers sur toutes les plates-formes, qu'elles soient compatibles ou non avec l'API File System Access. Ce qui en fait un candidat idéal pour la bibliothèque browser-fs-access.

Je peux, par exemple, commencer un dessin sur mon iPhone, l'enregistrer (techniquement, le télécharger, car Safari n'est pas compatible avec l'API File System Access) dans le dossier "Téléchargements" de mon iPhone, ouvrir le fichier sur mon ordinateur (après l'avoir transféré depuis mon téléphone), le modifier et le remplacer par mes modifications, ou même l'enregistrer en tant que nouveau fichier.

Dessin Excalidraw sur un iPhone.
Démarrer un dessin Excalidraw sur un iPhone où l'API File System Access n'est pas prise en charge, mais où un fichier peut être enregistré (téléchargé) dans le dossier "Téléchargements".
Dessin Excalidraw modifié dans Chrome sur le bureau.
Ouverture et modification du dessin Excalidraw sur l'ordinateur, où l'API File System Access est prise en charge et où le fichier est accessible via l'API.
Écraser le fichier d&#39;origine avec les modifications
Remplacement du fichier d'origine par les modifications apportées au fichier de dessin Excalidraw d'origine. Le navigateur affiche une boîte de dialogue me demandant si c'est correct.
Enregistrement des modifications dans un nouveau fichier de dessin Excalidraw.
Enregistrement des modifications dans un nouveau fichier Excalidraw. Le fichier d'origine n'est pas modifié.

Exemple de code concret

Vous trouverez ci-dessous un exemple concret de browser-fs-access tel qu'il est utilisé dans Excalidraw. Cet extrait est tiré de /src/data/json.ts. La méthode saveAsJSON() transmet un descripteur de fichier ou null à la méthode fileSave() de browser-fs-access, ce qui entraîne un écrasement lorsqu'un descripteur est fourni ou un enregistrement dans un nouveau fichier si ce n'est pas le cas.

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
  fileHandle: any,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: "application/json",
  });
  const name = `${appState.name}.excalidraw`;
  (window as any).handle = await fileSave(
    blob,
    {
      fileName: name,
      description: "Excalidraw file",
      extensions: ["excalidraw"],
    },
    fileHandle || null,
  );
};

export const loadFromJSON = async () => {
  const blob = await fileOpen({
    description: "Excalidraw files",
    extensions: ["json", "excalidraw"],
    mimeTypes: ["application/json"],
  });
  return loadFromBlob(blob);
};

Considérations concernant l'interface utilisateur

Que ce soit dans Excalidraw ou dans votre application, l'UI doit s'adapter à la situation de prise en charge du navigateur. Si l'API File System Access est prise en charge (if ('showOpenFilePicker' in window) {}), vous pouvez afficher un bouton Enregistrer sous en plus d'un bouton Enregistrer. Les captures d'écran ci-dessous montrent la différence entre la barre d'outils principale de l'application Excalidraw responsive sur iPhone et sur Chrome pour ordinateur. Notez que sur iPhone, le bouton Enregistrer sous est manquant.

Barre d&#39;outils de l&#39;application Excalidraw sur iPhone avec un seul bouton &quot;Enregistrer&quot;.
Barre d'outils de l'application Exacalidraw sur iPhone avec un seul bouton Enregistrer.
Barre d&#39;outils de l&#39;application Excalidraw sur Chrome pour ordinateur, avec les boutons &quot;Enregistrer&quot; et &quot;Enregistrer sous&quot;.
Barre d'outils de l'application Excalidraw dans Chrome avec un bouton Enregistrer et un bouton Enregistrer sous sélectionné.

Conclusions

Techniquement, travailler avec des fichiers système fonctionne sur tous les navigateurs modernes. Sur les navigateurs compatibles avec l'API File System Access, vous pouvez améliorer l'expérience en permettant l'enregistrement et l'écrasement réels des fichiers (pas seulement le téléchargement) et en permettant aux utilisateurs de créer des fichiers où ils le souhaitent, tout en restant fonctionnels dans les navigateurs non compatibles avec l'API File System Access. browser-fs-access vous facilite la vie en traitant les subtilités de l'amélioration progressive et en rendant votre code aussi simple que possible.

Remerciements

Cet article a été relu par Joe Medley et Kayce Basques. Merci aux contributeurs à Excalidraw pour leur travail sur le projet et pour avoir examiné mes requêtes pull. Image principale par Ilya Pavlov sur Unsplash.