はじめに
前回の記事では Dexie.js で IndexedDB を実用レベルに引き上げる方法を紹介しました。IndexedDB は手軽ですが、キーバリュー型のストレージであり、複雑なクエリや JOIN は苦手です。
「ブラウザの中でも SQL を書きたい」——その要望に応えるのが SQLite WASM です。
SQLite の C ソースコードを WebAssembly にコンパイルし、ブラウザ上でフル機能のリレーショナル DB を動かします。
永続化には Origin Private File System(OPFS) を組み合わせることで、IndexedDB を超えるパフォーマンスが得られます。
IndexedDB との比較
| IndexedDB | SQLite WASM + OPFS | |
|---|---|---|
| データモデル | キーバリュー(オブジェクトストア) | リレーショナル(テーブル + SQL) |
| クエリ言語 | JavaScript API(カーソル、インデックス) | SQL |
| JOIN | 不可(アプリ層で結合) | 可能 |
| トランザクション | 暗黙コミット(リクエスト完了時) | 明示的(BEGIN / COMMIT) |
| 書き込み性能 | 中程度 | OPFS 経由で高速 |
| ブラウザ対応 | 全モダンブラウザ | Chrome 102+, Firefox 111+, Safari 17.4+ |
単一テーブルの CRUD であれば IndexedDB(+ Dexie.js)で十分ですが、複数テーブルの結合や集計が頻繁に発生するなら SQLite の方が自然に書けます。
セットアップ
公式の SQLite WASM ビルドを使う方法と、コミュニティのラッパーライブラリを使う方法があります。ここでは npm パッケージとして使いやすい @sqlite.org/sqlite-wasm を紹介します。
$ pnpm add @sqlite.org/sqlite-wasmSQLite WASM は Web Worker 内で動かすのが推奨です。OPFS の同期アクセス API(createSyncAccessHandle)は Worker 専用のため、メインスレッドからは使えません。
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
const sqlite3 = await sqlite3InitModule();const db = new sqlite3.oo1.OpfsDb("/app.sqlite3");
db.exec(` CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) )`);OpfsDb を使うことで、データは OPFS 上のファイルとして永続化されます。ブラウザを閉じてもデータは残り、次回アクセス時にそのまま読み込まれます。
CRUD 操作
SQL をそのまま書けるのが最大の利点です。
// 作成db.exec({ sql: "INSERT INTO tasks (title) VALUES (?)", bind: ["記事を書く"],});
// 読み取りconst rows = db.exec({ sql: "SELECT * FROM tasks WHERE done = 0 ORDER BY created_at DESC", returnValue: "resultRows", rowMode: "object",});
// 更新db.exec({ sql: "UPDATE tasks SET done = 1 WHERE id = ?", bind: [1],});
// 削除db.exec({ sql: "DELETE FROM tasks WHERE id = ?", bind: [1],});IndexedDB ではアプリ層で実装する必要があった集計やサブクエリも、SQL の表現力でそのまま書けます。
const stats = db.exec({ sql: ` SELECT done, COUNT(*) as count, MIN(created_at) as oldest FROM tasks GROUP BY done `, returnValue: "resultRows", rowMode: "object",});OPFS とは何か
Origin Private File System は Web 標準のファイルシステム API です。通常のファイルシステムとは異なり、オリジン(ドメイン)ごとに隔離されたプライベートな領域を提供します。
SQLite WASM にとって OPFS が重要な理由は 同期的なファイル I/O が可能な点です。IndexedDB 経由での永続化は非同期のため、SQLite のような同期的なファイルアクセスを前提とする設計とは相性が悪く、パフォーマンスが劣化します。OPFS の createSyncAccessHandle は Worker 内限定ですが、バイト単位の同期読み書きを提供するため、SQLite のネイティブに近い I/O パターンを再現できます。
メインスレッドとの通信
DB 操作は Worker 内で行うため、メインスレッドからは postMessage で通信します。
const worker = new Worker( new URL("./sqlite-worker.ts", import.meta.url), { type: "module" },);
function query(sql: string, bind?: unknown[]): Promise<unknown[]> { return new Promise((resolve) => { const id = crypto.randomUUID(); worker.addEventListener( "message", (e) => { if (e.data.id === id) resolve(e.data.rows); }, { once: true }, ); worker.postMessage({ id, sql, bind }); });}
const tasks = await query("SELECT * FROM tasks WHERE done = 0");実際のアプリケーションでは、この通信層を薄いラッパーとして整備し、コンポーネント側からは通常の非同期関数として呼び出せるようにしておくのがおすすめです。
制約と注意点
-
Worker 必須
OPFS の同期アクセスは Worker 内でしか使えません。メインスレッドで使う場合は IndexedDB バックエンドにフォールバックしますが、パフォーマンスは大幅に落ちます。 -
同時アクセス
同一オリジンの複数タブから同じ DB ファイルを開くと、ロック競合が発生します。SharedWorker で DB アクセスを一元化するか、Web Locks API で排他制御を設計する必要があります。 -
WASM のサイズ
SQLite WASM のバイナリは gzip 圧縮後で約 400 KB です。初回ロードのコストとして許容できるかはアプリの性質次第です。 -
デバッグ
OPFS のファイルは DevTools から直接参照しにくいのが現状です。Chrome のnavigator.storage.getDirectory()で探索するか、アプリ側にエクスポート機能を用意しておくと開発が楽になります。
まとめ
- SQLite WASM はブラウザ上でフル機能の SQL を実行でき、OPFS と組み合わせると高速な永続化が可能
- IndexedDB が苦手な JOIN や集計を SQL の表現力でカバーできる
- Worker 内での実行が前提。メインスレッドとは
postMessageで通信する設計が必要 - 複数タブの同時アクセスや WASM サイズなど、導入前に検討すべき制約がある
単純な CRUD なら Dexie.js + IndexedDB、複雑なクエリが必要なら SQLite WASM + OPFS、というのが現時点での使い分けの目安です。