💾

ブラウザでローカル保存したい / OPFS Web Worker編

に公開

はじめに

最近Webブラウザからローカル保存するOPFSというものにハマってます。
前回 はかんたんに読み書きする方法を調べてみました。
その後も調べてみると、ファイルを排他制御した上で同期的に書き込む createSyncAccessHandle() というAPIを見つけました。

パフォーマンス上有利みたいですね。
毎回 Promise を解決するオーバーヘッドがなく、直接ファイルシステムに書き込めるからでしょうか??

マウスを動かすたびにx,y座標をevents.jsonlに書き込み、space keyを押すとVector計算した結果を出力するものを作って試してみました。

Web Worker上で動くぞ OPFS

createSyncAccessHandleはWeb Worker上でしか動かないみたいです。
同期的に書き込むとUIをロックしてしまうからでしょうかね??

さっそく環境を作ってみましょう。
僕は pnpm create vite . で作っています。
言語はTypeScriptの方が好みですが、どっちでもいいです。
もしtsを使う場合はtsconfig.jsonのlibに WebWorker を追加しましょう。
こんな感じです。

"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],

まずはmain thread

import Worker from './worker?worker'

const worker = new Worker()

window.addEventListener('mousemove', event => {
  const data = {
    x: event.clientX,
    y: event.clientY,
  }
  worker.postMessage({
    data: JSON.stringify(data),
  })
})

window.addEventListener('keydown', async event => {
  if (event.code !== 'Space') {
    return
  }

  const root = await navigator.storage.getDirectory()
  const handle = await root.getFileHandle('events.jsonl')
  const file = await handle.getFile()
  const content = await file.text()

  const lines = content.trim().split('\n').filter(Boolean)
  const result = lines.reduce(
    (acc, line) => {
      const { x, y } = JSON.parse(line)
      return { x: acc.x + x, y: acc.y + y, events: acc.events + 1 }
    },
    { x: 0, y: 0, events: 0 }
  )

  console.log(result)
})

viteでのworkerのimportは ?worker が必要ですので忘れず入れてください。

次にWeb Worker

let accessHandle: FileSystemSyncAccessHandle | null = null
let writePosition = 0

self.addEventListener(
  'message',
  async (event: MessageEvent<{ data: string }>) => {
    const { data } = event.data
    if (!accessHandle) {
      const root = await navigator.storage.getDirectory()
      const draftHandle = await root.getFileHandle('events.jsonl', {
        create: true,
      })
      accessHandle = await draftHandle.createSyncAccessHandle()
      // 初回に最後のpositionを取得し、以降encodeしたbyteの長さを足してwriteメソッドに引き渡す
      writePosition = accessHandle.getSize()
    }

    const line = `${data}\n`
    const encoded = new TextEncoder().encode(line)
    // 同期的に書き込んでる
    accessHandle.write(encoded, { at: writePosition })
    writePosition += encoded.byteLength
    accessHandle.flush()
  }
)

まとめ

createSyncAccessHandleを使ってOPFSに同期的に書き込めました。
前回の createWritable と比べると:

createWritable createSyncAccessHandle
動作場所 どこでも Web Workerのみ
書き込み 非同期(await必要) 同期(await不要)
排他制御 なし あり
パフォーマンス 普通 高速

今回はmousemoveのたびに書き込むという激しい使い方をしましたが、問題なく動きました。

append-onlyでログを残す使い方には良いのですが、集計は結構しんどいのでProjection用にDuckDBやSQLiteのwasmを使ってみたいですね。

Discussion