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

ブラウザは長い間、ファイルやディレクトリを処理できていました。File API には、ウェブ アプリケーションでファイル オブジェクトを表す機能や、プログラムでそれらを選択してデータにアクセスする機能があります。でも、もっと近くで見ると、キラキラ光るものはきっと金色じゃない。

ファイルを扱う従来の方法は

ファイルを開く

デベロッパーは <input type="file"> 要素を使用して、ファイルを開いて読み取ることができます。最も簡単な形式では、ファイルは次のコードサンプルのようになります。input オブジェクトは FileList を提供します。以下の例では、1 つの File のみで構成されています。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ではなく、ポニーフィルです。アプリをできるだけ小さく抑えるために必要な機能だけを(静的または動的に)インポートできます。 使用できるメソッドは、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 に少し貢献しています。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 がサポートされているため、API を介してファイルにアクセスできるデスクトップで Excalidraw の図形描画を開いて変更します。
元のファイルに変更を反映して上書きします。
元のファイルを元の 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 によってレビューされました。このプロジェクトへの取り組みと pull リクエストの審査に協力してくれた Excalidraw の協力者に感謝します。ヒーロー画像(作成者: Ilya Pavlov、Unsplash)