File System Access API: ローカル ファイルへのアクセスを簡素化

File System Access API を使用すると、ウェブアプリはユーザーのデバイス上のファイルやフォルダを直接読み取ったり、変更を保存したりできます。

公開日: 2024 年 8 月 19 日

File System Access API を使用すると、IDE、写真や動画の編集ツール、テキスト エディタなど、ユーザーのローカル デバイス上のファイルを操作する強力なウェブアプリを構築できます。ユーザーがウェブアプリにアクセス権を付与すると、この API を使用して、ユーザーのデバイス上のファイルやフォルダに対する変更を直接読み取ったり保存したりできます。File System Access API は、ファイルの読み取りと書き込みだけでなく、ディレクトリを開いてその内容を列挙する機能も提供します。

ファイルの読み取りと書き込みを扱ったことがある方には、これから説明する内容の多くは馴染みのあるものだと思います。すべてのシステムが同じであるとは限らないため、ぜひお読みください。

FileSystem Access API は、Windows、macOS、ChromeOS、Linux、Android のほとんどの Chromium ブラウザでサポートされています。例外として、Brave では現在フラグの背後でのみ利用可能です。

File System Access API の使用

File System Access API の機能と有用性を紹介するために、単一ファイルのテキスト エディタを作成しました。テキスト ファイルを開いて編集したり、変更をディスクに保存したり、新しいファイルを作成して変更をディスクに保存したりできます。凝ったものではありませんが、コンセプトを理解するのに十分な情報を提供します。

ブラウザ サポート

Browser Support

  • Chrome: 86.
  • Edge: 86.
  • Firefox: not supported.
  • Safari: not supported.

Source

特徴検出

File System Access API がサポートされているかどうかを確認するには、目的のピッカー メソッドが存在するかどうかを確認します。

if ('showOpenFilePicker' in self) {
  // The `showOpenFilePicker()` method of the File System Access API is supported.
}

試してみる

File System Access API の実際の動作については、テキスト エディタのデモをご覧ください。

ローカル ファイル システムからファイルを読み取る

最初に取り組むユースケースは、ユーザーにファイルを選択してもらい、そのファイルを開いてディスクから読み取るというものです。

読み取るファイルを選択するようユーザーに求める

File System Access API のエントリ ポイントは window.showOpenFilePicker() です。呼び出されると、ファイル選択ツール ダイアログが表示され、ユーザーにファイルの選択を求めます。ユーザーがファイルを選択すると、API はファイル ハンドルの配列を返します。オプションの options パラメータを使用すると、ファイル選択ツールの動作に影響を与えることができます。たとえば、ユーザーが複数のファイル、ディレクトリ、または異なるファイルタイプを選択できるようにします。オプションを指定しない場合、ファイル選択ツールではユーザーが 1 つのファイルを選択できます。これはテキスト エディタに最適です。

他の多くの強力な API と同様に、showOpenFilePicker() の呼び出しは安全なコンテキストで行う必要があり、ユーザー操作内から呼び出す必要があります。

let fileHandle;
butOpenFile.addEventListener('click', async () => {
  // Destructure the one-element array.
  [fileHandle] = await window.showOpenFilePicker();
  // Do something with the file handle.
});

ユーザーがファイルを選択すると、showOpenFilePicker() はハンドルの配列を返します。この場合、ファイルとのやり取りに必要なプロパティとメソッドを含む 1 つの FileSystemFileHandle を含む 1 要素の配列です。

後で使用できるように、ファイル ハンドルへの参照を保持しておくと便利です。ファイルへの変更を保存したり、他のファイル操作を実行したりする際に必要になります。

ファイル システムからファイルを読み取る

ファイルへのハンドルを取得したら、ファイルのプロパティを取得したり、ファイル自体にアクセスしたりできます。ひとまず、その内容を読み上げます。handle.getFile() を呼び出すと、Blob を含む File オブジェクトが返されます。BLOB からデータを取得するには、そのメソッドslice()stream()text()arrayBuffer())のいずれかを呼び出します。

const file = await fileHandle.getFile();
const contents = await file.text();

FileSystemFileHandle.getFile() によって返される File オブジェクトは、ディスク上の基盤となるファイルが変更されていない限り、読み取り専用です。ディスク上のファイルが変更されると、File オブジェクトが読み取り不能になり、変更されたデータを読み取るために新しい File オブジェクトを取得する getFile() を再度呼び出す必要があります。

すべてを組み合わせる

ユーザーが [開く] ボタンをクリックすると、ブラウザにファイル選択ツールが表示されます。ファイルを選択すると、アプリがその内容を読み取って <textarea> に入れます。

let fileHandle;
butOpenFile.addEventListener('click', async () => {
  [fileHandle] = await window.showOpenFilePicker();
  const file = await fileHandle.getFile();
  const contents = await file.text();
  textArea.value = contents;
});

ファイルをローカル ファイル システムに書き込む

テキスト エディタでファイルを保存するには、[保存] と [名前を付けて保存] の 2 つの方法があります。Save は、以前に取得したファイル ハンドルを使用して、変更を元のファイルに書き戻します。しかし、[名前を付けて保存] は新しいファイルを作成するため、新しいファイル ハンドルが必要です。

新しいファイルを作成する

ファイルを保存するには、showSaveFilePicker() を呼び出します。これにより、ファイル選択ツールが「保存」モードで表示され、ユーザーは保存に使用する新しいファイルを選択できます。テキスト エディタについては、.txt 拡張機能が自動的に追加されるようにしたかったため、追加のパラメータを指定しました。

async function getNewFileHandle() {
  const options = {
    types: [
      {
        description: 'Text Files',
        accept: {
          'text/plain': ['.txt'],
        },
      },
    ],
  };
  const handle = await window.showSaveFilePicker(options);
  return handle;
}

ディスクへの変更を保存する

ファイルへの変更を保存するすべてのコードは、GitHubテキスト エディタのデモで確認できます。コア ファイル システムのインタラクションは fs-helpers.js にあります。最も単純な場合、プロセスは次のコードのようになります。各ステップを順に説明します。

// fileHandle is an instance of FileSystemFileHandle..
async function writeFile(fileHandle, contents) {
  // Create a FileSystemWritableFileStream to write to.
  const writable = await fileHandle.createWritable();
  // Write the contents of the file to the stream.
  await writable.write(contents);
  // Close the file and write the contents to disk.
  await writable.close();
}

ディスクへのデータの書き込みには、WritableStream のサブクラスである FileSystemWritableFileStream オブジェクトを使用します。ファイル ハンドル オブジェクトで createWritable() を呼び出して、ストリームを作成します。createWritable() が呼び出されると、ブラウザはまず、ユーザーがファイルへの書き込み権限を付与しているかどうかを確認します。書き込み権限が付与されていない場合、ブラウザはユーザーに権限を求めるプロンプトを表示します。権限が付与されていない場合、createWritable()DOMException をスローし、アプリはファイルに書き込むことができません。テキスト エディタでは、DOMException オブジェクトは saveFile() メソッドで処理されます。

write() メソッドは、テキスト エディタに必要な文字列を受け取ります。BufferSource または Blob を受け取ることもできます。たとえば、ストリームを直接パイプできます。

async function writeURLToFile(fileHandle, url) {
  // Create a FileSystemWritableFileStream to write to.
  const writable = await fileHandle.createWritable();
  // Make an HTTP request for the contents.
  const response = await fetch(url);
  // Stream the response into the file.
  await response.body.pipeTo(writable);
  // pipeTo() closes the destination pipe by default, no need to close it.
}

ストリーム内で seek() または truncate() を使用して、特定の位置にあるファイルを更新したり、ファイルのサイズを変更したりすることもできます。

推奨されるファイル名と開始ディレクトリを指定する

多くの場合、アプリでデフォルトのファイル名や場所を提案することが望ましいでしょう。たとえば、テキスト エディタは Untitled ではなく Untitled Text.txt というデフォルトのファイル名を提案したい場合があります。これを行うには、showSaveFilePicker オプションの一部として suggestedName プロパティを渡します。

const fileHandle = await self.showSaveFilePicker({
  suggestedName: 'Untitled Text.txt',
  types: [{
    description: 'Text documents',
    accept: {
      'text/plain': ['.txt'],
    },
  }],
});

デフォルトの開始ディレクトリについても同様です。テキスト エディタをビルドしている場合は、デフォルトの documents フォルダでファイルの保存またはファイルを開くダイアログを開始したいでしょう。一方、画像エディタの場合は、デフォルトの pictures フォルダで開始したいでしょう。次のように、showSaveFilePickershowDirectoryPicker()showOpenFilePicker メソッドに startIn プロパティを渡すことで、デフォルトの開始ディレクトリを提案できます。

const fileHandle = await self.showOpenFilePicker({
  startIn: 'pictures'
});

既知のシステム ディレクトリのリストは次のとおりです。

  • desktop: ユーザーのデスクトップ ディレクトリ(存在する場合)。
  • documents: ユーザーが作成したドキュメントが通常保存されるディレクトリ。
  • downloads: ダウンロードしたファイルが通常保存されるディレクトリ。
  • music: 通常、音声ファイルが保存されるディレクトリ。
  • pictures: 写真やその他の静止画像が通常保存されるディレクトリ。
  • videos: 動画や映画が通常保存されるディレクトリ。

既知のシステム ディレクトリの他に、既存のファイルまたはディレクトリ ハンドルを startIn の値として渡すこともできます。ダイアログが同じディレクトリで開きます。

// Assume `directoryHandle` is a handle to a previously opened directory.
const fileHandle = await self.showOpenFilePicker({
  startIn: directoryHandle
});

さまざまなファイル選択ツールの目的を指定する

アプリケーションによっては、目的ごとに異なるピッカーが用意されている場合があります。たとえば、リッチテキスト エディタでは、ユーザーがテキスト ファイルを開くことはできますが、画像をインポートすることもできます。デフォルトでは、各ファイル選択ツールは最後に記憶された場所で開きます。この問題を回避するには、ピッカーのタイプごとに id 値を保存します。id が指定されている場合、ファイル選択ツール実装は、その id 用に最後に使用されたディレクトリを個別に記憶します。

const fileHandle1 = await self.showSaveFilePicker({
  id: 'openText',
});

const fileHandle2 = await self.showSaveFilePicker({
  id: 'importImage',
});

IndexedDB にファイル ハンドルまたはディレクトリ ハンドルを保存する

ファイル ハンドルとディレクトリ ハンドルはシリアル化可能です。つまり、ファイル ハンドルまたはディレクトリ ハンドルを IndexedDB に保存したり、postMessage() を呼び出して同じトップレベル オリジン間で送信したりできます。

ファイルまたはディレクトリのハンドルを IndexedDB に保存すると、状態を保存したり、ユーザーがどのファイルまたはディレクトリを操作していたかを記憶したりできます。これにより、最近開いたファイルや編集したファイルのリストを保持したり、アプリを開いたときに最後に開いたファイルを再度開くことを提案したり、以前の作業ディレクトリを復元したりすることが可能になります。テキスト エディタには、ユーザーが最近開いた 5 つのファイルのリストが保存され、これらのファイルに再びアクセスできるようになっています。

次のコード例は、ファイル ハンドルとディレクトリ ハンドルの保存と取得を示しています。Glitch で実際に動作する様子を確認できます。(ここでは、簡潔にするために idb-keyval ライブラリを使用します)。

import { get, set } from 'https://unpkg.com/idb-keyval@5.0.2/dist/esm/index.js';

const pre1 = document.querySelector('pre.file');
const pre2 = document.querySelector('pre.directory');
const button1 = document.querySelector('button.file');
const button2 = document.querySelector('button.directory');

// File handle
button1.addEventListener('click', async () => {
  try {
    const fileHandleOrUndefined = await get('file');
    if (fileHandleOrUndefined) {
      pre1.textContent = `Retrieved file handle "${fileHandleOrUndefined.name}" from IndexedDB.`;
      return;
    }
    const [fileHandle] = await window.showOpenFilePicker();
    await set('file', fileHandle);
    pre1.textContent = `Stored file handle for "${fileHandle.name}" in IndexedDB.`;
  } catch (error) {
    alert(error.name, error.message);
  }
});

// Directory handle
button2.addEventListener('click', async () => {
  try {
    const directoryHandleOrUndefined = await get('directory');
    if (directoryHandleOrUndefined) {
      pre2.textContent = `Retrieved directroy handle "${directoryHandleOrUndefined.name}" from IndexedDB.`;
      return;
    }
    const directoryHandle = await window.showDirectoryPicker();
    await set('directory', directoryHandle);
    pre2.textContent = `Stored directory handle for "${directoryHandle.name}" in IndexedDB.`;
  } catch (error) {
    alert(error.name, error.message);
  }
});

保存されたファイルまたはディレクトリのハンドルと権限

権限はセッション間で常に保持されるとは限らないため、queryPermission() を使用して、ユーザーがファイルまたはディレクトリへの権限を付与しているかどうかを確認する必要があります。まだリクエストしていない場合は、requestPermission() に電話して(再)リクエストします。これは、ファイル ハンドルとディレクトリ ハンドルでも同様に機能します。それぞれ fileOrDirectoryHandle.requestPermission(descriptor) または fileOrDirectoryHandle.queryPermission(descriptor) を実行する必要があります。

テキスト エディタで、ユーザーがすでに権限を付与しているかどうかを確認し、必要に応じてリクエストを行う verifyPermission() メソッドを作成しました。

async function verifyPermission(fileHandle, readWrite) {
  const options = {};
  if (readWrite) {
    options.mode = 'readwrite';
  }
  // Check if permission was already granted. If so, return true.
  if ((await fileHandle.queryPermission(options)) === 'granted') {
    return true;
  }
  // Request permission. If the user grants permission, return true.
  if ((await fileHandle.requestPermission(options)) === 'granted') {
    return true;
  }
  // The user didn't grant permission, so return false.
  return false;
}

読み取りリクエストで書き込み権限をリクエストすることで、権限プロンプトの数を減らしました。ユーザーはファイルを開くときに 1 つのプロンプトが表示され、読み取りと書き込みの両方の権限を付与します。

ディレクトリを開いてその内容を列挙する

ディレクトリ内のすべてのファイルを列挙するには、showDirectoryPicker() を呼び出します。ユーザーがピッカーでディレクトリを選択すると、FileSystemDirectoryHandle が返されます。これにより、ディレクトリのファイルを列挙してアクセスできます。デフォルトでは、ディレクトリ内のファイルへの読み取りアクセス権が付与されますが、書き込みアクセス権が必要な場合は、メソッドに { mode: 'readwrite' } を渡すことができます。

butDir.addEventListener('click', async () => {
  const dirHandle