File System Standard(ファイル システム標準)では、オリジン プライベート ファイル システム(OPFS)が、ページのオリジンに対して非公開でユーザーに表示されないストレージ エンドポイントとして導入されています。これにより、パフォーマンスが高度に最適化された特殊な種類のファイルにアクセスできます。
ブラウザ サポート
配信元の非公開ファイル システムは、最新のブラウザでサポートされており、File System Living Standard の Web Hypertext Application Technology Working Group(WHATWG)によって標準化されています。
対応ブラウザ
- <ph type="x-smartling-placeholder">
- <ph type="x-smartling-placeholder">
- <ph type="x-smartling-placeholder">
- <ph type="x-smartling-placeholder">
目的
パソコン上のファイルを思い浮かべると、おそらくファイル階層が思い浮かぶでしょう。ファイルはフォルダに整理され、オペレーティング システムのファイル エクスプローラで探索できます。たとえば、Windows では Tom というユーザーの To Do リストは C:\Users\Tom\Documents\ToDo.txt
に存在する可能性があります。この例では、ToDo.txt
がファイル名、Users
、Tom
、Documents
がフォルダ名です。Windows の `C:` はドライブのルート ディレクトリを表します。
ウェブ上のファイルを操作する従来の方法
ウェブ アプリケーションで To Do リストを編集する場合の通常のフローは次のとおりです。
- ファイルをサーバーにアップロードするか、クライアントで
<input type="file">
を使用して開きます。 - ユーザーが変更を加えると、JavaScript 経由でプログラムで
click()
挿入されたファイルが<a download="ToDo.txt>
ダウンロードダウンロードされます。 - フォルダを開く場合は、
<input type="file" webkitdirectory>
の特別な属性を使用します。この属性は、独自の名前にもかかわらず、実質的に普遍的なブラウザ サポートを備えています。
ウェブ上でファイルを扱う最新の方法
このフローは、ファイルの編集に対するユーザーの考え方を反映しているわけではなく、入力ファイルのコピーがダウンロードされることになります。そのため、File System Access API では、showOpenFilePicker()
、showSaveFilePicker()
、showDirectoryPicker()
という 3 つの選択ツールを導入しました。これらは、名前が示すとおりに動作します。これにより、次のようにフローが有効になります。
showOpenFilePicker()
でToDo.txt
を開き、FileSystemFileHandle
オブジェクトを取得します。- ファイル ハンドルの
getFile()
メソッドを呼び出して、FileSystemFileHandle
オブジェクトからFile
を取得します。 - ファイルを変更して、ハンドルで
requestPermission({mode: 'readwrite'})
を呼び出します。 - ユーザーが権限リクエストを承認したら、変更を元のファイルに保存します。
- または、
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-origin" を返します。同じ生成元を共有するすべてのページは、同じ生成元の非公開ファイル システム データを参照できるため、https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/
は前の例と同じ詳細を確認できます。各オリジンには独自の独立したオリジン用プライベート ファイル システムがあるため、https://developer.chrome.com
オリジンのプライベート ファイル システムは https://web.dev
などとは完全に異なります。Windows では、ユーザーに表示されるファイル システムのルート ディレクトリは C:\\
です。
オリジンのプライベート ファイル システムと同等のものは、非同期メソッドを呼び出してアクセスされるオリジンごとに最初は空のルート ディレクトリです。
navigator.storage.getDirectory()
。
ユーザーに表示されるファイル システムと配信元の非公開ファイル システムの比較については、次の図をご覧ください。この図は、ルート ディレクトリ以外はすべて概念的に同じであり、データとストレージのニーズに応じてファイルとフォルダの階層を整理、配置することを示しています。
配信元の非公開ファイル システムの詳細
ブラウザの他のストレージ メカニズム(localStorage や IndexedDB など)と同様に、配信元の非公開ファイル システムにはブラウザの割り当て制限が適用されます。ユーザーがすべての閲覧データまたはすべてのサイトデータを消去すると、配信元の非公開ファイル システムも削除されます。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);
メインスレッドまたはウェブワーカー
配信元の非公開ファイル システムを使用する方法には、メインスレッドで使用する方法と、ウェブ ワーカーで使用する方法の 2 つがあります。ウェブ ワーカーはメインスレッドをブロックできません。つまり、このコンテキストでは 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
で可能なあらゆるコンテキストで使用できます。特に、FileReader
、URL.createObjectURL()
、createImageBitmap()
、XMLHttpRequest.send()
は Blobs
と Files
の両方に対応しています。必要な場合は、FileSystemFileHandle
の「frees」から 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
を直接 push することで、この関数を簡素化できます。
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;
};
ウェブ ワーカーでオリジンの非公開ファイル システムを使用する
前述のように、ウェブ ワーカーはメインスレッドをブロックできません。そのため、このコンテキストでは同期メソッドが許可されています。
同期アクセス ハンドルの取得
可能な限り高速なファイル操作のエントリ ポイントは 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()
はメインスレッドでのみ公開され、Worker スレッドでは公開されないため、必ずそこでコードを実行してください。
// 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 を参照)、OPFS Explorer の Chrome 拡張機能を使用して配信元の非公開ファイル システムをデバッグしてください。上の新しいファイルやフォルダの作成のスクリーンショットは、ちなみに拡張機能から直接撮ったものです。
拡張機能をインストールしたら、Chrome DevTools を開いて [OPFS Explorer] タブを選択します。これでファイル階層を調べることができます。ファイル名をクリックして元の非公開ファイル システムからユーザーに表示されるファイル システムにファイルを保存し、ゴミ箱アイコンをクリックしてファイルとフォルダを削除します。
デモ
デモで、WebAssembly にコンパイルされた SQLite データベースのバックエンドとして使用する、オリジンの非公開ファイル システムの動作(OPFS Explorer 拡張機能をインストールしている場合)をご覧ください。Glitch のソースコードもぜひチェックしてください。以下の埋め込みバージョンでは、オリジンの非公開ファイル システムのバックエンドを使用していませんが(iframe はクロスオリジンのため)、別のタブでデモを開くと、使用しています。
まとめ
WHATWG で規定されているとおり、元の非公開ファイル システムは、ウェブ上のファイルの使用方法や操作方法を形作っています。これにより、ユーザーに表示されるファイル システムでは実現できなかった新しいユースケースが実現しました。Apple、Mozilla、Google といった主要なブラウザ ベンダーはすべて参加しており、共通のビジョンを持っています。元の非公開ファイル システムの開発は、非常に協力的な取り組みであり、デベロッパーとユーザーからのフィードバックは、その進捗に不可欠です。Google は標準の改良と改善を続けていますので、whatwg/fs リポジトリに関するフィードバックを Issue または pull リクエストの形式でお送りください。
関連リンク
- ファイル システム標準仕様
- File System Standard リポジトリ
- オリジン プライベート ファイル システム WebKit を使用した File System API の投稿
- OPFS Explorer 拡張機能
謝辞
この記事は、Austin Sully、Etienne Noël、Rachel Andrew がレビューしました。ヒーロー画像作成: Christina Rumpf(Unsplash より)。