OPFS入門|ブラウザ専用ファイルシステムへデータを保存する

鍵付きの小型書類キャビネットとノートPCを置いたOPFSのイメージ写真 ブラウザDB・ストレージ
カテゴリー
ブラウザDB・ストレージ
公開日
2026.09.06

はじめに

Webアプリへデータを保存する方法として、localStorageやIndexedDBがよく使われます。しかし、SQLiteや画像編集アプリのように、ファイルに近い形で大量のバイナリデータを読み書きしたい場合もあります。

Origin Private File System(OPFS)は、Webサイトのオリジンごとに用意される、ブラウザ管理の非公開ファイルシステムです。

この記事では、OPFSへmemo.txtを作成し、メモの保存、読み込み、削除を行うアプリを作ります。さらに、保存容量と永続化要求の結果も画面へ表示します。

OPFSとは

OPFSはFile System APIが提供する保存領域です。通常のエクスプローラーやFinderで利用者が直接管理するファイルとは異なり、Webサイトのオリジン専用領域としてブラウザが管理します。

アクセスの入口は次のコードです。

const root = await navigator.storage.getDirectory();

返されるFileSystemDirectoryHandleが、そのオリジンのOPFSルートを表します。

オリジン単位で分離される

オリジンは、主にスキーム、ホスト名、ポート番号の組で決まります。

オリジンA: http://localhost:8000
オリジンB: http://localhost:3000

この2つはポートが違うため、別のオリジンです。一方で、同じオリジンにある複数ページやタブは同じOPFSを参照できます。

利用者の許可ダイアログは表示されない

OPFSはサイト専用領域なので、showOpenFilePicker()で通常のファイルを選ぶ場合とは異なり、ファイルごとの許可ダイアログを必要としません。

その代わり、OPFS内のファイルは通常のファイル管理画面から直接見える保存先ではありません。利用者へファイルを渡したい場合は、ダウンロード処理やFile System Access APIを別途実装します。

localStorageとの違い

項目localStorageOPFS
データ形式文字列のキーと値ファイルとディレクトリ
API同期メインスレッドでは非同期
大きなデータ向かないファイルベース処理に向く
Worker基本的に利用不可利用可能
同期ファイル操作なしDedicated Worker内のSyncAccessHandle
サイトデータ削除影響を受ける影響を受ける

OPFSは「消えない安全なハードディスク」ではありません。ブラウザのサイトデータ削除、ストレージ管理、プライベートブラウジングなどの影響を受けます。

完成するアプリ

次の機能を実装します。

  • memo.txtへメモを保存
  • ページ再読み込み後にメモを復元
  • メモファイルを削除
  • 使用量と利用可能量の推定値を表示
  • 永続ストレージを要求
  • OPFS非対応時に操作を無効化

HTMLを作成する

次の内容をindex.htmlとして保存します。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>OPFSメモ</title>
    <style>
      body { width: min(720px, calc(100% - 32px)); margin: 40px auto; font-family: system-ui; }
      textarea { width: 100%; min-height: 220px; box-sizing: border-box; padding: 12px; }
      button { margin: 12px 8px 0 0; padding: 8px 14px; }
      dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; }
    </style>
  </head>
  <body>
    <main>
      <h1>OPFSメモ</h1>
      <label for="memo">メモ</label>
      <textarea id="memo" maxlength="100000"></textarea>
      <div>
        <button id="save" type="button">保存</button>
        <button id="load" type="button">読み込み</button>
        <button id="delete" type="button">削除</button>
        <button id="persist" type="button">永続化を要求</button>
      </div>
      <p id="status" role="status" aria-live="polite"></p>
      <h2>ストレージ情報</h2>
      <dl>
        <dt>使用量</dt><dd id="usage">-</dd>
        <dt>推定上限</dt><dd id="quota">-</dd>
        <dt>永続化状態</dt><dd id="persisted">-</dd>
      </dl>
    </main>
    <script type="module">
      const FILE_NAME = "memo.txt";
      const memo = document.querySelector("#memo");
      const status = document.querySelector("#status");
      const buttons = [...document.querySelectorAll("button")];

      function formatBytes(bytes) {
        if (!Number.isFinite(bytes)) return "取得できません";
        const units = ["B", "KB", "MB", "GB"];
        let value = bytes;
        let unit = 0;
        while (value >= 1024 && unit < units.length - 1) {
          value /= 1024;
          unit += 1;
        }
        return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
      }

      async function getRoot() {
        if (!navigator.storage?.getDirectory) {
          throw new Error("このブラウザはOPFSに対応していません。");
        }
        return navigator.storage.getDirectory();
      }

      async function saveMemo() {
        const root = await getRoot();
        const handle = await root.getFileHandle(FILE_NAME, { create: true });
        const writable = await handle.createWritable();

        try {
          await writable.write(memo.value);
        } finally {
          await writable.close();
        }
      }

      async function loadMemo() {
        const root = await getRoot();
        try {
          const handle = await root.getFileHandle(FILE_NAME);
          const file = await handle.getFile();
          memo.value = await file.text();
          return true;
        } catch (error) {
          if (error instanceof DOMException && error.name === "NotFoundError") {
            memo.value = "";
            return false;
          }
          throw error;
        }
      }

      async function deleteMemo() {
        const root = await getRoot();
        try {
          await root.removeEntry(FILE_NAME);
          memo.value = "";
          return true;
        } catch (error) {
          if (error instanceof DOMException && error.name === "NotFoundError") {
            return false;
          }
          throw error;
        }
      }

      async function updateStorageInfo() {
        const estimate = await navigator.storage.estimate();
        document.querySelector("#usage").textContent = formatBytes(estimate.usage);
        document.querySelector("#quota").textContent = formatBytes(estimate.quota);
        const isPersisted = await navigator.storage.persisted();
        document.querySelector("#persisted").textContent = isPersisted ? "永続" : "ベストエフォート";
      }

      async function runAction(action, successMessage) {
        buttons.forEach((button) => { button.disabled = true; });
        try {
          const result = await action();
          status.textContent = typeof successMessage === "function" ? successMessage(result) : successMessage;
          await updateStorageInfo();
        } catch (error) {
          console.error(error);
          status.textContent = `操作に失敗しました: ${String(error)}`;
        } finally {
          buttons.forEach((button) => { button.disabled = false; });
        }
      }

      document.querySelector("#save").addEventListener("click", () =>
        runAction(saveMemo, "memo.txtへ保存しました。"),
      );
      document.querySelector("#load").addEventListener("click", () =>
        runAction(loadMemo, (found) => found ? "memo.txtを読み込みました。" : "保存済みメモはありません。"),
      );
      document.querySelector("#delete").addEventListener("click", () =>
        runAction(deleteMemo, (deleted) => deleted ? "memo.txtを削除しました。" : "削除するメモはありません。"),
      );
      document.querySelector("#persist").addEventListener("click", () =>
        runAction(
          async () => navigator.storage.persist(),
          (granted) => granted ? "永続化が許可されました。" : "永続化は許可されませんでした。",
        ),
      );

      if (!navigator.storage?.getDirectory) {
        buttons.forEach((button) => { button.disabled = true; });
        status.textContent = "このブラウザはOPFSに対応していません。";
      } else {
        await runAction(loadMemo, (found) => found ? "保存済みメモを復元しました。" : "新しいメモを入力できます。" );
      }
    </script>
  </body>
</html>

ローカルサーバーで開く

python -m http.server 8000

http://localhost:8000を開きます。file://で直接開かず、安全なコンテキストとして扱われるlocalhostから実行してください。

保存処理の仕組み

const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle("memo.txt", { create: true });
const writable = await handle.createWritable();
await writable.write("保存する内容");
await writable.close();

getFileHandle()create: trueは、ファイルがなければ作成する指定です。createWritable()で書き込み用ストリームを取得し、最後にclose()します。

今回のコードはfinallyからclose()を呼んでいますが、書き込み途中の例外時にはabort()を使う設計も検討してください。実際のアプリでは保存中断時の扱いを明確にします。

読み込みと削除

存在しないファイルをgetFileHandle()で開くとNotFoundErrorになります。保存データがないことは初回利用時の正常な状態なので、一般エラーと分けて処理しています。

削除はディレクトリハンドルのremoveEntry()を使います。

await root.removeEntry("memo.txt");

容量と永続化

navigator.storage.estimate()は、現在の使用量とクォータの推定値を返します。正確な空きディスク容量や将来保存できる量を保証する値ではありません。

navigator.storage.persist()は、ストレージを永続扱いにするよう要求します。ユーザーエージェントが判断するため、falseが返る場合があり、許可ダイアログが必ず表示されるわけでもありません。

const granted = await navigator.storage.persist();

永続化が許可されても、利用者が明示的にサイトデータを削除すればOPFSは消えます。バックアップが必要なデータはエクスポート機能やサーバー同期を別途用意します。

Worker専用の同期API

OPFSにはFileSystemSyncAccessHandleを使った同期アクセスがあります。ただし、これはOPFS内のファイルをDedicated Workerから扱う場合に限られます。

メインスレッドで同期ファイルI/Oを許すと画面操作を止めるため、この制限があります。SQLite WASMなど、同期ファイルAPIを前提にするプログラムで重要です。

通常のメモアプリでは、今回の非同期APIで十分です。高速なランダムアクセスが必要になってからWorkerとSyncAccessHandleを検討します。

動作確認

正常系

  1. メモを保存してページを再読み込みする
  2. 保存内容が復元される
  3. 日本語、改行、絵文字を保存できる
  4. 削除後に再読み込みして空になる
  5. 使用量が保存前後で変化する

エラー系

  1. 初回読み込みでmemo.txtがなくても致命的エラーにしない
  2. OPFS非対応時はボタンを無効化する
  3. プライベートブラウジング終了後のデータ消失を確認する
  4. サイトデータ削除後にメモがなくなることを確認する
  5. 保存処理中にボタンを連打できないことを確認する

よくあるトラブル

保存ファイルがエクスプローラーに見つからない

正常です。OPFSは利用者が通常のファイルとして直接管理する領域ではありません。利用者へ渡す場合はBlobを作り、ダウンロードまたは保存ピッカーを実装します。

別ポートで開いたらデータがない

ポートが違うと別オリジンです。開発サーバーのポートを変更すると別のOPFSになります。

永続化要求がfalseになる

永続化は要求であり、Webアプリが強制できません。データ消失が許容できない用途では、OPFSだけを唯一の保存先にしないでください。

向いている用途

  • SQLite WASMのデータベースファイル
  • オフライン編集アプリの作業ファイル
  • 大きなバイナリデータ
  • 利用者が直接見る必要のないキャッシュ
  • WebAssembly製アプリの仮想ファイル

小さな設定値だけならIndexedDBやlocalStorageの方が実装しやすい場合があります。必要なデータ構造とアクセス方法から選びます。

まとめ

OPFSを使うと、Webサイトのオリジン専用領域へファイルとディレクトリを保存できます。

許可ダイアログなしで利用でき、高速なWorker専用同期アクセスもありますが、サイトデータ削除やクォータの影響を受けます。重要データではエクスポート、同期、バックアップまで設計してください。

次の記事では、SQLiteのWebAssembly版とOPFSを組み合わせ、SQLデータベースをブラウザへ永続化します。

参考リンク

コメント

タイトルとURLをコピーしました