browser-fs-access ライブラリを使用したファイルとディレクトリの読み取りと書き込み

ブラウザは長い間、ファイルとディレクトリを処理してきました。File API は、ウェブ アプリケーションでファイル オブジェクトを表す機能、およびファイル オブジェクトをプログラムで選択してデータにアクセスする機能を提供します。しかし、よく見てみると、すべてが金色に輝いているわけではありません。

従来のファイル処理方法

ファイルを開く

デベロッパーは、<input type="file"> 要素を使用してファイルを開いて読み取ることができます。最も単純な形式では、ファイルを開くコードは次のようになります。input オブジェクトは FileList を返します。この場合、File が 1 つだけ含まれます。File は特定の種類の Blob であり、Blob が使用できる任意のコンテキストで使用できます。

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

ディレクトリを開く

フォルダ(またはディレクトリ)を開くには、<input webkitdirectory> 属性を設定します。それ以外は、上記と同じように機能します。ベンダー接頭辞が付いた名前にもかかわらず、webkitdirectory は Chromium ブラウザと WebKit ブラウザだけでなく、従来の EdgeHTML ベースの Edge や Firefox でも使用できます。

ファイルの保存(ダウンロード)

従来、ファイルを保存するには、ファイルをダウンロードする方法しかありませんでした。これは、<a download> 属性によって可能になります。Blob を指定して、アンカーの href 属性を blob: URL に設定できます。この URL は 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();
};

問題

ダウンロード アプローチの大きな欠点は、従来の開く→編集→保存のフローを実行できないことです。つまり、元のファイルを上書きする方法がありません。代わりに、[保存] を実行するたびに、元のファイルの新しいコピーがオペレーティング システムのデフォルトのダウンロード フォルダに作成されます。

File System Access API

File System Access API を使用すると、開く操作と保存操作の両方が大幅に簡素化されます。また、真の保存も可能になります。つまり、ファイルの保存場所を選択できるだけでなく、既存のファイルを上書きすることもできます。

ファイルを開く

File System Access API では、ファイルを開くには window.showOpenFilePicker() メソッドを 1 回呼び出すだけです。この呼び出しはファイル ハンドルを返します。このハンドルから、getFile() メソッドを介して実際の File を取得できます。

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

ディレクトリを開く

window.showDirectoryPicker() を呼び出してディレクトリを開き、ファイル ダイアログ ボックスでディレクトリを選択できるようにします。

ファイルの保存

ファイルの保存も同様に簡単です。ファイルハンドルから createWritable() を使用して書き込み可能なストリームを作成し、ストリームの write() メソッドを呼び出して Blob データを書き込み、最後に 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);
  }
};

browser-fs-access の導入

File System Access API は非常に優れていますが、まだ広く利用可能ではありません

File System Access API のブラウザ サポート表。すべてのブラウザが「サポートなし」または「フラグあり」とマークされています。
File System Access API のブラウザ サポート表。 (出典

そのため、File System Access API は段階的な拡張と見なしています。そのため、ブラウザでサポートされている場合はそれを使用したいが、サポートされていない場合は従来のアプローチを使用したい。ただし、サポートされていない JavaScript コードを不必要にダウンロードしてユーザーに不便を強いることは絶対に避けたい。browser-fs-access ライブラリは、この課題に対する私の答えです。

設計の指針

File System Access API は今後も変更される可能性があるため、browser-fs-access API はそれをモデル化したものではありません。つまり、このライブラリは ポリフィルではなく、ポニーフィルです。アプリをできるだけ小さく保つために、必要な機能を(静的または動的)にのみインポートできます。使用できるメソッドは、適切な名前の fileOpen()directoryOpen()fileSave() です。内部的には、ライブラリは File System Access API がサポートされているかどうかを機能検出してから、対応するコードパスをインポートします。

browser-fs-access ライブラリを使用する

これらの 3 つの方法は直感的に使用できます。アプリで許可する mimeTypes またはファイル extensions を指定し、multiple フラグを設定して、複数のファイルまたはディレクトリの選択を許可または禁止できます。詳細については、browser-fs-access API のドキュメントをご覧ください。次のコードサンプルは、画像ファイルを開いて保存する方法を示しています。

// 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',
  });
})();

デモ

上記のコードの動作は、Glitch のデモで確認できます。ソースコードも同様に利用できます。セキュリティ上の理由から、クロスオリジン サブフレームはファイル選択ツールを表示できないため、この記事にデモを埋め込むことはできません。

実環境での browser-fs-access ライブラリ

空き時間には、インストール可能な PWA である Excalidraw に少しずつ貢献しています。これは、手書きのような感覚で図を簡単にスケッチできるホワイトボード ツールです。レスポンシブに対応しており、小さなスマートフォンから大画面のパソコンまで、さまざまなデバイスで適切に動作します。つまり、File System Access API をサポートしているかどうかにかかわらず、さまざまなプラットフォーム上のファイルを処理する必要があります。そのため、browser-fs-access ライブラリの候補として適しています。

たとえば、iPhone で描画を開始し、iPhone のダウンロード フォルダに保存(技術的には、Safari は File System Access API をサポートしていないため、ダウンロード)し、(スマートフォンから転送した後)パソコンでファイルを開いて、ファイルを変更し、変更内容で上書きしたり、新しいファイルとして保存したりできます。

iPhone で作成した Excalidraw の図。
File System Access API はサポートされていないものの、ファイルがダウンロードフォルダに保存(ダウンロード)できる iPhone で Excalidraw の描画を開始する。
パソコン版 Chrome で変更した Excalidraw の描画。
File System Access API がサポートされているデスクトップで Excalidraw の図を開いて変更します。これにより、API を介してファイルにアクセスできます。
変更を加えた元のファイルが上書きされます。
元の Excalidraw 描画ファイルの変更内容で元のファイルを上書きします。ブラウザに、この操作を許可するかどうかを尋ねるダイアログが表示されます。
変更を新しい Excalidraw 描画ファイルに保存する。
変更を新しい Excalidraw ファイルに保存します。元のファイルは変更されません。

実際のコードサンプル

以下に、Excalidraw で使用されている browser-fs-access の実際の例を示します。この抜粋は /src/data/json.ts から取得されています。特に注目すべきは、saveAsJSON() メソッドがファイル ハンドルまたは null を browser-fs-access の fileSave() メソッドに渡す方法です。これにより、ハンドルが指定された場合は上書きされ、指定されなかった場合は新しいファイルに保存されます。

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);
};

UI に関する考慮事項

Excalidraw でもアプリでも、UI はブラウザのサポート状況に適応する必要があります。File System Access API がサポートされている場合(if ('showOpenFilePicker' in window) {})、[保存] ボタンに加えて [名前を付けて保存] ボタンを表示できます。以下のスクリーンショットは、iPhone と Chrome デスクトップで Excalidraw のレスポンシブなメインアプリ ツールバーの違いを示しています。iPhone では [名前を付けて保存] ボタンが表示されないことに注意してください。

iPhone の Excalidraw アプリ ツールバー。[保存] ボタンのみが表示されています。
iPhone 版 Excalidraw アプリのツールバー。[保存] ボタンのみが表示されています。
Chrome パソコン版の Excalidraw アプリ ツールバー。[保存] ボタンと [名前を付けて保存] ボタンがあります。
Chrome の Excalidraw アプリ ツールバー。[保存] ボタンとフォーカスされている [名前を付けて保存] ボタンがあります。

まとめ

システム ファイルの操作は、技術的にはすべての最新ブラウザで動作します。File System Access API をサポートするブラウザでは、ファイルをダウンロードするだけでなく、ファイルを実際に保存、上書きできるようにすることで、ユーザー エクスペリエンスを向上させることができます。また、File System Access API をサポートしていないブラウザでも機能するようにします。browser-fs-access を使用すると、プログレッシブ エンハンサメントの微妙な部分を処理し、コードをできるだけシンプルにすることで、作業が楽になります。

謝辞

この記事は、Joe MedleyKayce Basques が確認しました。プロジェクトへの貢献とプルリクエストの審査にご協力いただいた Excalidraw のコントリビューターの皆様に感謝いたします。Unsplash の Ilya Pavlov によるヒーロー画像