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

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

ファイルの従来の処理方法

ファイルを開く

デベロッパーは、<input type="file"> 要素を使用してファイルを開いて読み取ることができます。最も単純な形式では、ファイルを開く処理は次のコードサンプルのようになります。input オブジェクトは FileList を返します。次の例では、File が 1 つだけ含まれています。FileBlob の一種で、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 は非常に優れていますが、まだ広く利用できるわけではありません

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

これが、私が File System Access API をプログレッシブ エンハンスメントと見なす理由です。そのため、ブラウザがサポートしている場合はそれを使用し、サポートしていない場合は従来の方法を使用したいと考えています。その際、サポートされていない JavaScript コードを不必要にダウンロードしてユーザーに負担をかけることは避けたいと考えています。この課題に対する私の答えが browser-fs-access ライブラリです。

設計理念

File System Access API は今後も変更される可能性が高いため、browser-fs-access API はそれをモデル化していません。つまり、ライブラリは polyfill ではなく、ponyfill です。アプリをできるだけ小さく保つために必要な機能を(静的または動的に)排他的にインポートできます。使用できるメソッドは、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',
  });
})();

デモ

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

browser-fs-access ライブラリの実際の使用例

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

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

iPhone に表示された Excalidraw の図。
iPhone で Excalidraw の図面を開始します。この iPhone では File System Access API はサポートされていませんが、ファイルを [ダウンロード] フォルダに保存(ダウンロード)できます。
パソコン版 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