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

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

ファイルを開く

デベロッパーは、<input type="file"> 要素を使用してファイルを開いたり、読み取ることができます。最も単純な形式では、ファイルを開くコードは次のようになります。input オブジェクトは FileList を提供します。このオブジェクトは、以下のケースでは 1 つの File で構成されています。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 属性を URL.createObjectURL() メソッドから取得できる blob: URL に設定できます。

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 は非常に優れた 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 ライブラリ

空き時間には、Excalidraw というインストール可能な PWA に少し貢献しています。これは、手書きのような感覚で図を簡単にスケッチできるホワイトボード ツールです。高い応答性を備え、小型のスマートフォンから大画面のパソコンにまで幅広く対応しています。 つまり、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 によるヒーロー画像