はじめに

前回、実際にサンプルプログラム(機能限定の簡易な OPFS のファイラー)を作って、OPFS への非同期操作の確認を行いました。

今回は、そのプログラムを編集し、OPFS への同期操作の確認行っていきたいと思います。

編集するにあたり、OPFS への同期操作は、メインスレッドでは実行できないため、Web Worker を新設し、OPFS 操作すべてをそちらに移設します。

Web Worker については、過去記事、「モダンJavaScript入門/Web Worker の基本」を参照してください。

それでは、サンプルプログラムの編集方針から順番に説明していきたいと思います。


サンプルプログラムの編集方針

  • OPFS へのアクセスは、すべて Web Worker に移設し、メインスレッドからは、直接 OPFS にアクセスしないようにする。
  • サンプルプログラムの画面や機能は、現在のものを踏襲する。
  • Web Worker 上での OPFS へのアクセスは、読み書きともに同期アクセスを使用する。
┌─────────────────────────────────────┐                 ┌────────────────────────┐
│ メインスレッド                       │                 │ バックグラウンドスレッド │
│                                     │                 │                        │
│  ┌────────────────▶┐                │                 │  ┌──────────────────┐  │
│  │  イベントループ  │                │                 │  │  Web Worker      │  │
│  │      ┌──────────┿──────────┐     │                 │  │                  │  │
│  │      ▼          ▼          ▼     │                 │  │                  │  │
│  │   画面描画  ユーザー入力  OPFS処理 ───── 処理依頼 ─────▶ │ OPFS 同期アクセス │  │
│  │      │          │          │     │                 │  │                  │  │
│  │      └──────────┿──────────┘     │                 │  └──────────────────┘  │
│  │                 ▼                │                 │                        │
│  └◀────────────────┘                │                 │                        │
└─────────────────────────────────────┘                 └────────────────────────┘

※ 技術的にはメインスレッドとWorkerの両方からOPFSへアクセスできますが、同じファイルへの同時アクセスでは競合や排他制御を考慮する必要があります。そのため、実際のアプリケーションでは、OPFSへのアクセスをWorkerに集約し、メインスレッドとはメッセージ通信でやり取りする設計が推奨されます。

┌─────────────────────────────────────┐                 ┌────────────────────────┐
│ メインスレッド                       │                 │ バックグラウンドスレッド │
│                                     │                 │                        │
│    ・生成と起動      ───────── new ──────────────▶│                        │
│                                     │                 │                        │
│    ・メッセージ送信      ────────── OPFS操作要求 ─────────▶  ・メッセージ受取り    │
│                                     │                 │      ファイル一覧    ───────┐
│                                     │                 │      ファイルコピ─   ───────┥
│                                     │                 │      ファイル読込    ───────┥
│                                     │                 │      ファイル削除    ───────┥
│                                     │                 │                            │
│    ・メッセージ受取      ◀───────── OPFS操作結果 ────────── ・メッセージ送信  ◀───────┘
│      画面反映                   │                 │                        |
│                                     │                 │                        │
└─────────────────────────────────────┘                 └────────────────────────┘


サンプルプログラムの動作イメージの確認

まずは、サンプルプログラムの実際の動きを確認していただこうと思います。

下記のボタンを押下することで、ポップアップ画面が表示され、サンプルプログラムが起動します。

※ ポップアップ画面外をクリックすると、画面が閉じ、サンプルプログラムは終了します。

サンプルプログラムの使い方については、過去記事、「データ保存技術/OPFS の動作確認(非同期操作)」を参照してください。


サンプルプログラムの編集

■ HTML( index.html )

後述のメインスレッド( script.js )での OPFS 操作がなくなったため、メインスレッドを EMS モジュールとして読み込む必要性がなく、下記のように変更してもいいのですが、

<head>
    :
  <script defer type="module" src="./script.js"></script>
</head>

👇

<head>
    :
  <script defer src="./script.js"></script>
</head>

今回は、非同期操作との対比を重視し、変更する必要のないものは、そのまま使用します。

■ CSS( style.css )

今回は、変更せずそのまま使用します。

■ メインスレッド( script.js )

OPFS へのアクセス部分を Web Worker に対するメッセージによる要求に変更し、結果をメッセージで受け取って処理するように変更します。

1. OPFS ルートディレクトリ取得を、Web Worker 起動に変更します。

// OPFS のルートディレクトリを取得
const root = await navigator.storage.getDirectory();

👇

const worker = new Worker("./worker.js"); // Web Worker 起動

※ const worker = new Worker("worker.js", { type: "module" }); を使用しないのは、後述の worker.js での import や TOP レベルでの await の必要性がないからです。

2. ファイル一覧項目の作成関数のパラメータを FileSystemFileHandle からファイル名に変更します。

// ファイル一覧項目の作成
function createFileItem(handle) {
  const fileItem = document.createElement("div");
  fileItem.id = handle.name;
  fileItem.textContent = `📄 ${handle.name}`;
  fileItem.classList.add("fileItem");
      :
}

👇

// ファイル一覧項目の作成
function createFileItem(fileName) {
  const fileItem = document.createElement("div");
  fileItem.id = fileName;
  fileItem.textContent = `📄 ${fileName}`;
  fileItem.classList.add("fileItem");
      :
}

※ メインスレッドにて、OPFS を直接アクセスしなくなった為、この変更が必要になっています。

3. ファイル一覧項目クリック時に、直接 OPFS ファイルを読み込んでいた処理を Web Worker に対するメッセージ要求に変更します。

  // ファイル一覧項目クリック時の処理
  fileItem.addEventListener("click", async (e) => {
    const file = await handle.getFile(); // OPFS 上のファイルの取得

    // 取得したファイルを iframe に表示
    if (currentObjectURL) URL.revokeObjectURL(currentObjectURL);
    currentObjectURL = URL.createObjectURL(file);
    viewArea.src = currentObjectURL;
        :
  });

👇

  // ファイル一覧項目クリック時の処理
  fileItem.addEventListener("click", async (e) => {
    worker.postMessage({ type: "read", fileName });
      :
  });

※ メッセージには、読み込みを希望する OPFS ファイルのファイル名を含めています。

4. 現在の OPFS のファイル一覧を取得し、画面に表示する処理を Web Worker に対するメッセージ要求に変更します。

// 現在の OPFS のファイル一覧を取得し、画面に表示
for await (const handle of root.values()) {
  const fileItem = createFileItem(handle);
  fileList.appendChild(fileItem);
}

👇

worker.postMessage({ type: "list" }); // ファイル一覧メッセージ

5. Web Worker からのメッセージを受け取り、対応した画面描画を行う処理を新規追加します。

worker.onmessage = (e) => {
  const result = e.data;
  switch (result.type) {
  case "list" :
    // list メッセージを受信したら、ファイル一覧を表示
    for (const fileName of result.files) {
      const fileItem = createFileItem(fileName);
      fileList.appendChild(fileItem);
    }
    break;
  case "copy" :
    // copy メッセージを受信したら、ステータスにコピー完了を表示
    status.textContent = `ファイルをコピーしました: ${result.fileName}`;
    break;
  case "read" :
    // read メッセージを受信したら、取得したファイルを iframe に表示
    if (currentObjectURL) URL.revokeObjectURL(currentObjectURL);
    currentObjectURL = URL.createObjectURL(result.blob);
    viewArea.src = currentObjectURL;
    break;
  case "delete" :
    // delete メッセージを受信したら、ステータスに削除完了を表示
    status.textContent = `削除しました: ${result.fileName}`;
    break;
  case "error" :
    // エラーメッセージを受信したら、ステータスにエラー内容を表示
    status.textContent = `エラーが発生しました: ${result.error}`;
    break;
  default :
    break;
  }
}

6. ファイル選択ポップアップでファイルが選択された時の処理を Web Worker に対するメッセージ要求に変更します。

// ファイル選択ポップアップでファイルが選択された時の処理
document.getElementById("input").addEventListener("change", async (e) => {
  const file = e.target.files[0];
  e.target.value = ""; // 次回、同じファイルを選択できるようにする

  if (!file) return; // ファイルが選択されなかった場合は何もしない

  let fileHandle;

  try {
    fileHandle = await root.getFileHandle(file.name); // OPFS 上に同一名のファイルがあるか確認

    // 同一名のファイルが既に存在する場合、上書きするか確認する
    if (!confirm(`${file.name}」は既に存在します。\n上書きしてもよろしいですか?`)) return;
  }
  catch (err) {
    if (err.name === "NotFoundError") {
      // 同一名のファイルが無い場合、新規作成する
      fileHandle = await root.getFileHandle(file.name, { create: true });

      const fileItem = createFileItem(fileHandle);
      fileList.appendChild(fileItem);
    } else {
      throw err;
    }
  }
  
  // OPFS ファイルに書き込み
  const writable = await fileHandle.createWritable(); // 書き込み用のストリームを作成
  await writable.write(file); // ユーザーファイルの内容を OPFS のファイルに書き込む
  await writable.close(); // 書き込み用のストリームを閉じる

  status.textContent = `ファイルをコピーしました: ${fileHandle.name}`;
});

👇

// ファイル選択ポップアップでファイルが選択された時の処理
document.getElementById("input").addEventListener("change", async (e) => {
  const file = e.target.files[0];
  e.target.value = ""; // 次回、同じファイルを選択できるようにする

  if (!file) return; // ファイルが選択されなかった場合は何もしない

  if (document.getElementById(file.name)) {
    // 同一名のファイルが既に存在する場合、上書きするか確認する
    if (!confirm(`${file.name}」は既に存在します。\n上書きしてもよろしいですか?`)) return;
  } else {
      // 同一名のファイルが無い場合、新規作成する
      const fileItem = createFileItem(file.name);
      fileList.appendChild(fileItem);
  }

  worker.postMessage({ type: "copy", file });
});

※ 今回は画面に表示している一覧と OPFS の内容が常に一致する前提のため、画面上の一覧から存在確認を行っています。実際のアプリケーションでは、Web Worker 側でも存在確認を行う設計の方が安全です。

※ メッセージには、コピー元となるユーザーファイルの File オブジェクトを含めています。

7. 削除ボタン押下時に、選択中のファイルを削除する処理を Web Worker に対するメッセージ要求に変更します。

// 削除ボタン押下時に、選択中のファイルを削除
document.getElementById("deleteBtn").addEventListener("click", async (e) => {
    :
  await root.removeEntry(selected.id); // ファイルの削除
    :
});

👇

// 削除ボタン押下時に、選択中のファイルを削除
document.getElementById("deleteBtn").addEventListener("click", async (e) => {
    :
  worker.postMessage({ type: "delete", fileName: selected.id });
    :
});

※ メッセージには、削除を希望する OPFS ファイルのファイル名を含めています。

■ Web Worker( worker.js )

メインスレッドからのメッセージ要求に従って、OPFS に同期アクセスし、結果をメッセージでメインスレッドに送信する処理を新設します。

  • OPFS ファイル一覧取得し、ファイル名の配列を戻す。
  • ユーザーファイルから OPFS ファイルへの同期アクセスによるファイルコピーを行う。
  • 指定された OPFS ファイルの同期読み込みを行い、結果を Blob データとして戻す。
  • 指定された OPFS フアイルを削除する。

動作確認を実施する

このテストで確認したいこと

このテストでは、次のことを確認していきたいと思います:

  • OPFS 上のファイルへの同期操作による書き込み
  • OPFS 上のファイルの同期操作による読み込み

実際の操作手順(ここが重要)

  1. 手順:サンプルプログラムの起動

👉 Web Worker が起動され、Web Worker 内での OPFS ルートの取得を確認します

  • navigator.storage.getDirectory()
  1. 手順:「コピー」により、ユーザーファイルからファイルを OPFS 上にコピーする。

👉 OPFS 上へのファイルへの同期書き込みを確認します

  • FileSystemDirectoryHandle.getFileHandle()
  • FileSystemFileHandle.createSyncAccessHandle()
  • FileSystemSyncAccessHandle.write()
  • FileSystemSyncAccessHandle.close()
  1. 手順:OPFS 上にコピーしたファイルを一覧上でクリックして、内容を表示する。

👉 OPFS 上のファイルの同期読み込みと、表示用 MIME タイプの取得を確認します。

  • FileSystemDirectoryHandle.getFileHandle()
  • FileSystemFileHandle.getFile()
  • FileSystemFileHandle.createSyncAccessHandle()
  • FileSystemSyncAccessHandle.getSize()
  • FileSystemSyncAccessHandle.read()
  • FileSystemSyncAccessHandle.close()
  1. 手順:F5や再読み込みを実行した後、テストプログラムを再起動する。

👉 OPFS 上へのファイル名一覧の取得と、コピー済みのファイルが消えずに残っていることを確認します

  • FileSystemDirectoryHandle.keys()
  1. 手順:「削除」により、選択したファイルを OPFS 上から削除する。

👉 OPFS 上からのファイルの削除を確認します

  • FileSystemDirectoryHandle.removeEntry()

テスト結果まとめ

  • OPFS の同期アクセスにおいても、非同期アクセスの時と同様に、まずディレクトリの先頭である root の FileSystemDirectoryHandle オブジェクトをブラウザから取得することが先決です。
  • 取得した FileSystemDirectoryHandle や FileSystemFileHandle の操作方法は、非同期アクセス時と同様です。
  • ファイルへの同期書き込みは、本来ランダムアクセスを前提としているため、ストリーム, Blob , File オブジェクトなどを渡すことができません。一度 ArrayBuffer へ変換する必要があります。
  • ファイルの同期読み込みは、ArrayBuffer への読み込みとなるため、画面への表示などには、MIME タイプを設定した Blob への変換が必要です。

サンプルプログラムのコード紹介

下記に、今回使用したサンプルプログラムのコードを記します。

HTML( index.html )

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>簡易 OPFS ファイラー</title>
  <link rel="stylesheet" href="./style.css">
  <script defer type="module" src="./script.js"></script>
</head>
<body>
  <p>
    <input id='input' type="file">
    <button id='copyBtn'>コピー</button>
    <button id='deleteBtn'>削除</button>
  </p>
  <div id='allArea'>
    <div id='fileList'></div>
    <iframe id='viewArea'></iframe>
  </div>
  <div id='status'></div>
</body>
</html>

CSS( style.css )

body {
  padding: 1rem;
  background-color: rgb(253, 191, 76);
  overflow: hidden;
}
button {
  width: 200px;
}
.selected {
  background-color: rgb(167, 167, 167);
}
#input {
  display: none;
}
#allArea {
  display: flex;
}
#fileList {
  border: solid 1px;
  background-color: white;
  height: 350px;
  width: 30%;
  box-shadow: 5px 5px 10px rgba(0,0,0,0.3);
  border-radius: 5px;
  font-size: small;
  overflow: auto;
}
.fileItem {
  cursor: pointer;
  border-bottom: solid 1px;
  padding: 0.5rem;
} 
#viewArea {
  margin: 0px 0px 0px 10px;
  border: solid 1px;
  background-color: white;
  height: 350px;
  width: 70%;
  box-shadow: 5px 5px 10px rgba(0,0,0,0.3);
  border-radius: 5px;
  overflow: auto;
}
#status {
    font-size: 1.25rem;
    font-weight: bold;
    color: red;
    margin-top: 1rem;
    margin-bottom: 1rem;
}

メインスレッド( script.js )

const fileList = document.getElementById("fileList");
const viewArea = document.getElementById("viewArea");
const status = document.getElementById("status");
let currentObjectURL = null;

const worker = new Worker("./worker.js"); // Web Worker 起動

// ファイル一覧項目の作成
function createFileItem(fileName) {
  const fileItem = document.createElement("div");
  fileItem.id = fileName;
  fileItem.textContent = `📄 ${fileName}`;
  fileItem.classList.add("fileItem");

  // ファイル一覧項目クリック時の処理
  fileItem.addEventListener("click", async (e) => {
    worker.postMessage({ type: "read", fileName });

    // 既にハイライト表示のものがあれば解除し、選択したファイルをハイライト表示
    const selected = document.querySelector(".selected");
    if (selected) {
      selected.classList.remove("selected");
    }
    e.target.classList.add("selected");

    status.textContent = `選択しました: ${fileName}`;
  });
  return fileItem; // 作成したファイル一覧項目を返す
}

worker.postMessage({ type: "list" }); // ファイル一覧メッセージ

worker.onmessage = (e) => {
  const result = e.data;
  switch (result.type) {
  case "list" :
    // list メッセージを受信したら、ファイル一覧を表示
    for (const fileName of result.files) {
      const fileItem = createFileItem(fileName);
      fileList.appendChild(fileItem);
    }
    break;
  case "copy" :
    // copy メッセージを受信したら、ステータスにコピー完了を表示
    status.textContent = `ファイルをコピーしました: ${result.fileName}`;
    break;
  case "read" :
    // read メッセージを受信したら、取得したファイルを iframe に表示
    if (currentObjectURL) URL.revokeObjectURL(currentObjectURL);
    currentObjectURL = URL.createObjectURL(result.blob);
    viewArea.src = currentObjectURL;
    break;
  case "delete" :
    // delete メッセージを受信したら、ステータスに削除完了を表示
    status.textContent = `削除しました: ${result.fileName}`;
    break;
  case "error" :
    // エラーメッセージを受信したら、ステータスにエラー内容を表示
    status.textContent = `エラーが発生しました: ${result.error}`;
    break;
  default :
    break;
  }
}

// コピーボタン押下時に、ファイル選択ポップアップを表示
document.getElementById("copyBtn").addEventListener("click", (e) => {
  document.getElementById("input").click();
});

// ファイル選択ポップアップでファイルが選択された時の処理
document.getElementById("input").addEventListener("change", async (e) => {
  const file = e.target.files[0];
  e.target.value = ""; // 次回、同じファイルを選択できるようにする

  if (!file) return; // ファイルが選択されなかった場合は何もしない

  if (document.getElementById(file.name)) {
    // 同一名のファイルが既に存在する場合、上書きするか確認する
    if (!confirm(`${file.name}」は既に存在します。\n上書きしてもよろしいですか?`)) return;
  } else {
      // 同一名のファイルが無い場合、新規作成する
      const fileItem = createFileItem(file.name);
      fileList.appendChild(fileItem);
  }

  worker.postMessage({ type: "copy", file });
});

// 削除ボタン押下時に、選択中のファイルを削除
document.getElementById("deleteBtn").addEventListener("click", async (e) => {
  const selected = document.querySelector(".selected");
  if (!selected) return; // 選択中のファイルが無い場合は何もしない

  worker.postMessage({ type: "delete", fileName: selected.id });

  selected.remove();

  // 不要になった Object URL を解放
  if (currentObjectURL) {
    URL.revokeObjectURL(currentObjectURL);
    currentObjectURL = null;
  }
  viewArea.src = "";
});

URL.createObjectURL() で生成した Object URL はブラウザ内部で管理されるため、不要になったら URL.revokeObjectURL() を呼び出して解放することをおすすめします。

Web Worker( worker.js )

self.onmessage = async (e) => {
  const parm = e.data;

  try {
    // OPFS のルートディレクトリを取得
    const root = await navigator.storage.getDirectory();

    // list 要求の場合は、ルートディレクトリのファイル一覧を取得して返す
    if (parm.type === "list") {
      const files = [];
      for await (const name of root.keys()) {
        files.push(name);
      }
      self.postMessage({ type: "list", files });
      return;
    }

    // copy 要求の場合は、ファイルを OPFS にコピーする
    if (parm.type === "copy") {
      const arrayBuf = await parm.file.arrayBuffer();
      const buffer = new Uint8Array(arrayBuf);

      const fileHandle = await root.getFileHandle(parm.file.name, { create: true });
      const accessHandle = await fileHandle.createSyncAccessHandle(); // 同期アクセスハンドルを作成

      try {
        accessHandle.write(buffer, { at: 0 }); // ファイルの先頭から書き込む
      } finally {
        accessHandle.close(); // 同期アクセスハンドルを閉じる
      }
      
      self.postMessage({ type: "copy", fileName: parm.file.name });
      return;
    }

    // read 要求の場合は、OPFS からファイルを読み込んで返す
    if (parm.type === "read") {
      const fileHandle = await root.getFileHandle(parm.fileName);

      const file = await fileHandle.getFile(); // ファイルの MIME タイプを取得するために、File オブジェクトを取得
      const mimeType = file.type || 'application/octet-stream'; // MIME タイプが取得できない場合は、デフォルトで application/octet-stream を使用

      const accessHandle = await fileHandle.createSyncAccessHandle(); // 同期アクセスハンドルを作成

      const buffer = new Uint8Array(accessHandle.getSize());
      try {
        accessHandle.read(buffer, { at: 0 }); // ファイルの先頭から読み込む
      } finally {
        accessHandle.close(); // 同期アクセスハンドルを閉じる
      }

      const blob = new Blob([buffer], {type: mimeType});

      self.postMessage({ type: "read", blob });
      return;
    }

    // delete 要求の場合は、OPFS からファイルを削除する
    if (parm.type === "delete") {
      await root.removeEntry(parm.fileName); // ファイルを削除
      self.postMessage({ type: "delete", fileName: parm.fileName });
      return;
    }

    // それ以外の要求は不明なメッセージとしてエラーを返す
    throw new Error("不明なメッセージです");

  } catch (e) {
    // エラーが発生した場合は、エラーメッセージを返す
    self.postMessage({
      type: "error",
      error: e instanceof Error ? e.message : String(e)
    });
  }
};

※ navigator.storage.getDirectory() はメッセージを受信するたびに実行しています。実際のアプリケーションでは一度だけ取得して使い回す設計も一般的ですが、本サンプルでは処理の流れを分かりやすくし、環境エラーも各操作時に検出できるよう、このような構成にしています。

※ 今回のサンプルでは、同期書き込み write() では、全部書ける前提で、戻り値を確認していません。しかし、ランダムアクセス用途では戻り値を確認することがあります。

※ 同期アクセスにも、flush() が存在しますが、close() 時にも反映されるので、今回は省いています。

※ 同期アクセスの read()write() 実行時に、エラーが発生した場合、同期ハンドルが閉じられない問題が発生しないように、実務では定番の try {...} finally { close(); } を利用しています。