送信元のプライベート ファイル システム

ファイル システム標準では、オリジン プライベート ファイル システム(OPFS)が、ページのオリジン専用のストレージ エンドポイントとして導入され、ユーザーには表示されません。OPFS は、パフォーマンスを重視して高度に最適化された特殊なタイプのファイルへのアクセス権をオプションとして提供します。

ブラウザ サポート

オリジンのプライベート ファイル システムは最新のブラウザでサポートされており、File System Living Standard の Web Hypertext Application Technology Working Group(WHATWG)によって標準化されています。

対応ブラウザ

  • 86
  • 86
  • 111
  • 15.2

ソース

目的

パソコン上のファイルといえば、ファイル階層を思い浮かべるかもしれません。ファイルは、オペレーティング システムのファイル エクスプローラで探索できるフォルダに整理されています。たとえば、Windows で Tom というユーザーの To Do リストが C:\Users\Tom\Documents\ToDo.txt に登録されている場合などです。この例では、ToDo.txt がファイル名、UsersTomDocuments がフォルダ名です。Windows の場合、「C:」はドライブのルート ディレクトリを表します。

ウェブ上のファイルを扱う従来の方法

ウェブ アプリケーションで To Do リストを編集する場合の通常のフローは次のとおりです。

  1. ユーザーがファイルをサーバーにアップロードするか、<input type="file"> を使用してクライアントで開く
  2. ユーザーが変更を加え、JavaScript を使用してプログラムで click() を挿入した <a download="ToDo.txt> を含むファイルをダウンロードします。
  3. フォルダを開く場合は、<input type="file" webkitdirectory> の特別な属性を使用します。この属性は独自の名前ですが、実質的に普遍的なブラウザ サポートを備えています。

ウェブ上のファイルを扱う最新の方法

このフローは、ユーザーによるファイル編集の考え方を表すものではなく、最終的には入力ファイルのコピーがダウンロードされます。そのため、File System Access API には、名前が示すとおりに動作する 3 つの選択ツールメソッド(showOpenFilePicker()showSaveFilePicker()showDirectoryPicker())が導入されました。これにより、次のようにフローが有効になります。

  1. showOpenFilePicker()ToDo.txt を開き、FileSystemFileHandle オブジェクトを取得します。
  2. FileSystemFileHandle オブジェクトから、ファイル ハンドルの getFile() メソッドを呼び出して File を取得します。
  3. ファイルを変更し、ハンドルで requestPermission({mode: 'readwrite'}) を呼び出します。
  4. ユーザーが権限リクエストを承認した場合は、変更を元のファイルに保存し直します。
  5. または、showSaveFilePicker() を呼び出して、ユーザーが新しいファイルを選択できるようにします。(ユーザーが以前に開いたファイルを選択すると、その内容は上書きされます)。繰り返し保存では、ファイル ハンドルを保持しておくと、ファイル保存ダイアログを再度表示する必要がなくなります。

ウェブ上のファイル操作に関する制限

これらのメソッドを介してアクセスできるファイルとフォルダは、ユーザーに表示されるファイル システム内に存在します。ウェブから保存されたファイル、具体的には実行可能ファイルにはウェブのマークが付けられます。そのため、危険性のあるファイルが実行される前に、オペレーティング システムによって追加の警告が表示されることがあります。追加のセキュリティ機能として、ウェブから取得したファイルもセーフ ブラウジングで保護されます。セーフ ブラウジングでは、わかりやすくするために、この記事の文脈ではクラウドベースのウイルススキャンと考えることができます。File System Access API を使用してファイルにデータを書き込む場合、書き込みはその場で行われず、一時ファイルが使用されます。上記のすべてのセキュリティ・チェックに合格しない限り、ファイル自体は変更されません。macOS などで可能な部分で改善が適用されているにもかかわらず、この処理によりファイル操作が比較的遅くなります。すべての write() 呼び出しは自己完結型であるため、内部でファイルを開き、指定されたオフセットをシークして、最後にデータを書き込みます。

処理の基盤としてのファイル

同時に、ファイルはデータを記録する優れた方法です。たとえば、SQLite ではデータベース全体が 1 つのファイルに保存されます。別の例として、画像処理で使用される mipmap があります。mipmap は、事前に計算され、最適化された画像シーケンスです。各画像は前の画像より解像度を徐々に下げるため、ズームなどの多くの処理が高速化されます。では、ウェブベースのファイル処理のパフォーマンス コストをかけずに、ウェブ アプリケーションがファイルのメリットを享受するにはどうすればよいでしょうか。答えは、オリジンのプライベート ファイル システムです。

ユーザー可視と送信元のプライベート ファイル システムの比較

オペレーティング システムのファイル エクスプローラを使用してユーザーに表示されるファイル システムとは異なり、ファイルやフォルダの読み取り、書き込み、移動、名前の変更は可能ですが、元の非公開ファイル システムはユーザーに表示されることを想定していません。送信元の限定公開ファイル システムのファイルとフォルダは、その名前が示すように、限定公開であり、より具体的には、サイトの「送信元」に対して限定公開です。DevTools コンソールで「location.origin」と入力して、ページのオリジンを確認します。たとえば、ページ https://developer.chrome.com/articles/ のオリジンは https://developer.chrome.com です(つまり、部分 /articles はオリジンの一部ではありません)。オリジンの理論について詳しくは、「same-site」と「same-origin」についてをご覧ください。同じオリジンを共有するすべてのページは同じオリジンの限定公開ファイル システム データを参照できるため、https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/ は前の例と同じ詳細を表示できます。各オリジンには独自の独立したオリジンのプライベート ファイル システムがあります。つまり、https://developer.chrome.com のオリジンのプライベート ファイル システムは、https://web.dev のものとは完全に異なります。Windows の場合、ユーザーに表示されるファイル システムのルート ディレクトリは C:\\ です。オリジンの非公開ファイル システムに相当するのは、非同期メソッド navigator.storage.getDirectory() を呼び出してアクセスされるオリジンごとの、最初は空のルート ディレクトリです。ユーザーに表示されるファイル システムと送信元の限定公開ファイル システムの比較については、次の図をご覧ください。この図は、ルート ディレクトリ以外はすべて概念的に同じで、ファイルとフォルダの階層を使用して、データとストレージのニーズに合わせて整理、配置していることを示しています。

ユーザーに表示されるファイル システムと送信元の限定公開ファイル システムの図、2 つのファイル階層の例。ユーザーに表示されるファイル システムのエントリ ポイントはシンボリック ハードディスクで、元のプライベート ファイル システムのエントリ ポイントはメソッド「navigator.storage.getDirectory」の呼び出しです。

送信元のプライベート ファイル システムの詳細

ブラウザの他のストレージ メカニズム(localStorageIndexedDB など)と同様に、送信元のプライベート ファイル システムにはブラウザの割り当て制限が適用されます。ユーザーがすべての閲覧データまたはすべてのサイトデータを削除すると、元の非公開ファイル システムも削除されます。navigator.storage.estimate() を呼び出し、結果として得られるレスポンス オブジェクトで usage エントリを参照して、アプリがすでに消費しているストレージの量を確認します。これは、usageDetails オブジェクトのストレージ メカニズムごとに分類されています。fileSystem エントリを個別に確認します。オリジンの非公開ファイル システムはユーザーに表示されないため、アクセス許可の確認やセーフ ブラウジング チェックは行われません。

ルート ディレクトリへのアクセス権の取得

ルート ディレクトリにアクセスするには、次のコマンドを実行します。空のディレクトリ ハンドル(より具体的には、FileSystemDirectoryHandle)ができあがります。

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

メインスレッドまたは Web Worker

オリジンのプライベート ファイル システムを使用するには、メインスレッドで使用する方法と、Web Worker で使用する方法があります。Web Worker はメインスレッドをブロックできません。つまり、このコンテキストでは API は同期的ですが、このパターンは一般的にメインスレッドでは許可されません。同期 API は Promise を扱う必要がないため、処理を高速化できます。また、ファイル操作は通常、WebAssembly にコンパイルできる C などの言語では同期的です。

// This is synchronous C code.
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

できるだけ高速なファイル操作が必要な場合や、WebAssembly を扱う場合は、Web Worker でオリジンのプライベート ファイル システムを使用するに進んでください。それ以外は、続きを読むことができます。

メインスレッドで送信元のプライベート ファイル システムを使用する

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

ルートフォルダを作成したら、getFileHandle() メソッドと getDirectoryHandle() メソッドを使用してそれぞれファイルとフォルダを作成します。ファイルまたはフォルダが存在しない場合は、{create: true} を渡すと作成されます。新しく作成されたディレクトリを出発点として使用し、これらの関数を呼び出して、ファイルの階層を構築します。

const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('my first nested folder', {create: true});

前のコードサンプルで示したファイル階層。

既存のファイルやフォルダにアクセスする

フォルダの名前がわかっている場合は、getFileHandle() メソッドまたは getDirectoryHandle() メソッドを呼び出してファイルまたはフォルダの名前を渡すことで、以前に作成したファイルやフォルダにアクセスします。

const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder');

読み取り用のファイル ハンドルに関連付けられたファイルを取得する

FileSystemFileHandle は、ファイル システム上のファイルを表します。関連する File を取得するには、getFile() メソッドを使用します。File オブジェクトは Blob の一種であり、Blob が使用できるすべてのコンテキストで使用できます。特に、FileReaderURL.createObjectURL()createImageBitmap()XMLHttpRequest.send() は、BlobsFiles の両方を受け入れます。そうすると、FileSystemFileHandle から File を取得するとデータが「解放」されるため、このデータにアクセスして、ユーザーに表示されるファイル システムで使用できるようになります。

const file = await fileHandle.getFile();
console.log(await file.text());

ストリーミングによるファイルへの書き込み

データをファイルにストリーミングするには、createWritable() を呼び出して FileSystemWritableFileStream を作成し、それに対してコンテンツを write() します。最後に、ストリーミングを close() する必要があります。

const contents = 'Some text';
// Get a writable stream.
const writable = await fileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(contents);
// Close the stream, which persists the contents.
await writable.close();

ファイルやフォルダを削除する

ファイルまたはフォルダを削除するには、ファイルまたはディレクトリ ハンドルの特定の remove() メソッドを呼び出します。すべてのサブフォルダを含むフォルダを削除するには、{recursive: true} オプションを渡します。

await fileHandle.remove();
await directoryHandle.remove({recursive: true});

または、ディレクトリ内の削除するファイルまたはフォルダの名前がわかっている場合は、removeEntry() メソッドを使用します。

directoryHandle.removeEntry('my first nested file');

ファイルやフォルダの移動と名前変更

move() メソッドを使用して、ファイルやフォルダの名前を変更したり移動したりします。移動と名前変更は、まとめて行うことも、単独で行うこともできます。

// Rename a file.
await fileHandle.move('my first renamed file');
// Move a file to another directory.
await fileHandle.move(nestedDirectoryHandle);
// Move a file to another directory and rename it.
await fileHandle
    .move(nestedDirectoryHandle, 'my first renamed and now nested file');

ファイルまたはフォルダのパスを解決する

参照ディレクトリを基準として特定のファイルまたはフォルダの場所を確認するには、resolve() メソッドを使用して、引数として FileSystemHandle を渡します。元の非公開ファイル システムにあるファイルまたはフォルダのフルパスを取得するには、navigator.storage.getDirectory() で取得した参照ディレクトリとしてルート ディレクトリを使用します。

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` is `['my first folder', 'my first nested folder']`.

2 つのファイルまたはフォルダ ハンドルが同じファイルまたはフォルダを参照しているかどうかを確認する

ハンドルが 2 つあり、それらが同じファイルまたはフォルダを指しているかどうかわからないことがあります。これを確認するには、isSameEntry() メソッドを使用します。

fileHandle.isSameEntry(nestedFileHandle);
// Returns `false`.

フォルダの内容を一覧表示する

FileSystemDirectoryHandle は、for await…of ループで反復処理する非同期イテレータです。非同期イテレータとして、entries() メソッド、values() メソッド、keys() メソッドもサポートしており、必要な情報に応じてこれらを選択できます。

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

フォルダとすべてのサブフォルダの内容を再帰的に一覧表示する

非同期ループや関数と再帰の組み合わせを扱う場合、間違いが起こりがちです。以下の関数は、フォルダとそのすべてのサブフォルダの内容(すべてのファイルとそのサイズを含む)を一覧表示する出発点として使用できます。ファイルサイズが必要ない場合は、directoryEntryPromises.push と指定して、handle.getFile() Promise ではなく handle を直接プッシュすることで、この関数を簡素化できます。

  const getDirectoryEntriesRecursive = async (
    directoryHandle,
    relativePath = '.',
  ) => {
    const fileHandles = [];
    const directoryHandles = [];
    const entries = {};
    // Get an iterator of the files and folders in the directory.
    const directoryIterator = directoryHandle.values();
    const directoryEntryPromises = [];
    for await (const handle of directoryIterator) {
      const nestedPath = `${relativePath}/${handle.name}`;
      if (handle.kind === 'file') {
        fileHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          handle.getFile().then((file) => {
            return {
              name: handle.name,
              kind: handle.kind,
              size: file.size,
              type: file.type,
              lastModified: file.lastModified,
              relativePath: nestedPath,
              handle
            };
          }),
        );
      } else if (handle.kind === 'directory') {
        directoryHandles.push({ handle, nestedPath });
        directoryEntryPromises.push(
          (async () => {
            return {
              name: handle.name,
              kind: handle.kind,
              relativePath: nestedPath,
              entries:
                  await getDirectoryEntriesRecursive(handle, nestedPath),
              handle,
            };
          })(),
        );
      }
    }
    const directoryEntries = await Promise.all(directoryEntryPromises);
    directoryEntries.forEach((directoryEntry) => {
      entries[directoryEntry.name] = directoryEntry;
    });
    return entries;
  };

ウェブ ワーカーでオリジンのプライベート ファイル システムを使用する

前述のように、Web Worker はメインスレッドをブロックできないため、このコンテキストでは同期メソッドが許可されます。

同期アクセス ハンドルの取得

最も高速なファイル操作のエントリ ポイントは FileSystemSyncAccessHandle です。これは、createSyncAccessHandle() を呼び出して通常の FileSystemFileHandle から取得します。

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

同期インプレース ファイル メソッド

同期アクセス ハンドルを設定すると、すべて同期的な高速なインプレース ファイル メソッドにアクセスできるようになります。

  • getSize(): ファイルのサイズをバイト単位で返します。
  • write(): バッファのコンテンツを、必要に応じて指定されたオフセットでファイルに書き込み、書き込まれたバイト数を返します。返された書き込みバイト数を確認することで、呼び出し元はエラーと部分書き込みを検出して処理できます。
  • read(): ファイルの内容をバッファに読み込みます。必要に応じて指定されたオフセットで読み込みます。
  • truncate(): ファイルのサイズを指定されたサイズに変更します。
  • flush(): ファイルの内容に、write() によるすべての変更が含まれるようにします。
  • close(): アクセス ハンドルを閉じます。

上記のメソッドをすべて使用した例を次に示します。

const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle('fast', {create: true});
const accessHandle = await fileHandle.createSyncAccessHandle();

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

// Initialize this variable for the size of the file.
let size;
// The current size of the file, initially `0`.
size = accessHandle.getSize();
// Encode content to write to the file.
const content = textEncoder.encode('Some text');
// Write the content at the beginning of the file.
accessHandle.write(content, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `9` (the length of "Some text").
size = accessHandle.getSize();

// Encode more content to write to the file.
const moreContent = textEncoder.encode('More content');
// Write the content at the end of the file.
accessHandle.write(moreContent, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `21` (the length of
// "Some textMore content").
size = accessHandle.getSize();

// Prepare a data view of the length of the file.
const dataView = new DataView(new ArrayBuffer(size));

// Read the entire file into the data view.
accessHandle.read(dataView);
// Logs `"Some textMore content"`.
console.log(textDecoder.decode(dataView));

// Read starting at offset 9 into the data view.
accessHandle.read(dataView, {at: 9});
// Logs `"More content"`.
console.log(textDecoder.decode(dataView));

// Truncate the file after 4 bytes.
accessHandle.truncate(4);

送信元の非公開ファイル システムからユーザーに表示されるファイル システムにファイルをコピーする

前述のように、元の非公開ファイル システムからユーザーに表示されるファイル システムにファイルを移動することはできませんが、ファイルをコピーすることはできます。showSaveFilePicker() はメインスレッドでのみ公開され、ワーカー スレッドでは公開されないため、必ずメインスレッドでコードを実行してください。

// On the main thread, not in the Worker. This assumes
// `fileHandle` is the `FileSystemFileHandle` you obtained
// the `FileSystemSyncAccessHandle` from in the Worker
// thread. Be sure to close the file in the Worker thread first.
const fileHandle = await opfsRoot.getFileHandle('fast');
try {
  // Obtain a file handle to a new file in the user-visible file system
  // with the same name as the file in the origin private file system.
  const saveHandle = await showSaveFilePicker({
    suggestedName: fileHandle.name || ''
  });
  const writable = await saveHandle.createWritable();
  await writable.write(await fileHandle.getFile());
  await writable.close();
} catch (err) {
  console.error(err.name, err.message);
}

送信元の非公開ファイル システムをデバッグする

組み込みの DevTools のサポートが追加されるまで(crbug/1284595 を参照)、Chrome 拡張機能の OPFS Explorer を使用してオリジンのプライベート ファイル システムをデバッグしてください。上記の「新しいファイルとフォルダの作成」セクションのスクリーンショットは、拡張機能から直接取得したものです。

Chrome ウェブストアの OPFS Explorer Chrome DevTools 拡張機能。

拡張機能をインストールしたら、Chrome DevTools を開いて [OPFS Explorer] タブを選択します。これで、ファイル階層を検査できるようになります。ファイル名をクリックして元の非公開ファイル システムからユーザーに表示されるファイル システムにファイルを保存し、ゴミ箱アイコンをクリックしてファイルやフォルダを削除します。

デモ

WebAssembly にコンパイルされた SQLite データベースのバックエンドとして使用するデモで、オリジンのプライベート ファイル システムの動作(OPFS Explorer 拡張機能をインストールしている場合)をご覧ください。Glitch のソースコードを必ず確認してください。以下の埋め込みバージョンでは、オリジンのプライベート ファイル システムのバックエンドが使用されていません(iframe はクロスオリジンであるため)。ただし、別のタブでデモを開くと、使用されていることがわかります。

まとめ

WHATWG が定めるように、オリジンのプライベート ファイル システムは、ウェブ上のファイルの使用方法とやり取りする方法を形成してきました。これにより、ユーザーに表示されるファイル システムでは実現できなかった新しいユースケースが可能になりました。Apple、Mozilla、Google といった主要なブラウザ ベンダーはすべて参加し、共通のビジョンを持っています。オリジンの限定公開ファイル システムの開発は、非常に共同で進められており、開発を進めるにはデベロッパーやユーザーからのフィードバックが不可欠です。引き続き標準の改良と改善を進めてまいりますので、問題または pull リクエストの形式で whatwg/fs リポジトリに関するフィードバックをお寄せください。

謝辞

この記事は、Austin SullyEtienne NoëlRachel Andrew によってレビューされました。ヒーロー画像(作成者: Christina RumpfUnsplash