sql.js 讓你在瀏覽器裡跑 SQLite,用法和伺服器端幾乎一樣。但有一個問題我在第一次用的時候才發現:關掉分頁,資料就不見了。
原因很直接——sql.js 把整個資料庫存在記憶體裡,沒有任何自動持久化機制。如果你想做一個真正能離線運作、重開瀏覽器資料還在的應用,你需要自己處理儲存這一層。
為什麼選 IndexedDB
第一個念頭通常是 localStorage,因為 API 簡單。但它有兩個致命限制:
- 容量上限約 5MB,超過就會拋出例外。一個稍微有點資料量的 SQLite 檔案輕鬆超過。
- 只能存字串,sql.js 匯出的是
Uint8Array,你得先轉 Base64 再存、讀的時候再轉回來,不只麻煩,轉換本身也會讓資料膨脹約 33%。
IndexedDB 沒有這兩個問題。它的儲存上限通常是磁碟空間的一定比例(現代瀏覽器一般允許幾百 MB 甚至更多),而且原生支援二進位資料,Uint8Array 直接存進去就好。
架構概述
整體架構非常清楚:
- sql.js:負責所有 SQL 查詢邏輯,資料存在記憶體中的
SQL.Database 實例 - IndexedDB:負責持久化,只存一個 key-value pair——資料庫的
Uint8Array 快照
兩者的邊界很明確,sql.js 不需要知道 IndexedDB 的存在,IndexedDB 也不需要理解 SQL。
封裝 IndexedDB 存取
原生 IndexedDB API 是基於事件回調的,直接用有點囉嗦。我習慣先包一個簡單的 Promise helper,省去後面的心智負擔。以下用原生 API 示範,不依賴任何額外套件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| // db-storage.js
const DB_NAME = 'app-storage';
const STORE_NAME = 'sqlite-db';
const DB_KEY = 'main';
// 開啟(或建立)IndexedDB
function openStorage() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (event) => {
// 第一次開啟時建立 object store
event.target.result.createObjectStore(STORE_NAME);
};
request.onsuccess = (event) => resolve(event.target.result);
request.onerror = (event) => reject(event.target.error);
});
}
// 將 Uint8Array 存入 IndexedDB
export async function saveDatabase(data) {
const idb = await openStorage();
return new Promise((resolve, reject) => {
const tx = idb.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.put(data, DB_KEY);
tx.oncomplete = () => resolve();
tx.onerror = (event) => reject(event.target.error);
});
}
// 從 IndexedDB 讀取 Uint8Array,若不存在則回傳 null
export async function loadDatabase() {
const idb = await openStorage();
return new Promise((resolve, reject) => {
const tx = idb.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(DB_KEY);
request.onsuccess = (event) => resolve(event.target.result ?? null);
request.onerror = (event) => reject(event.target.error);
});
}
|
這個 helper 只有兩個公開函式:saveDatabase 和 loadDatabase,職責清楚。
儲存資料庫
sql.js 的 db.export() 會把當前記憶體中的資料庫序列化成 Uint8Array,直接丟給 saveDatabase 就好:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| import initSqlJs from 'sql.js';
import { saveDatabase } from './db-storage.js';
const SQL = await initSqlJs({
// Wasm 檔案路徑,視你的打包設定調整
locateFile: (file) => `/wasm/${file}`,
});
// 建立一個新的記憶體資料庫
const db = new SQL.Database();
// 建立資料表
db.run(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
`);
// 新增資料
db.run('INSERT INTO todos (text) VALUES (?)', ['買牛奶']);
// 匯出並儲存到 IndexedDB
const data = db.export(); // 回傳 Uint8Array
await saveDatabase(data);
|
載入資料庫
應用程式啟動時,先嘗試從 IndexedDB 讀取。如果有資料,就用它初始化 sql.js;如果沒有(第一次使用),就建立一個全新的資料庫:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| import initSqlJs from 'sql.js';
import { loadDatabase, saveDatabase } from './db-storage.js';
async function initDb() {
const SQL = await initSqlJs({
locateFile: (file) => `/wasm/${file}`,
});
// 嘗試從 IndexedDB 還原
const saved = await loadDatabase();
let db;
if (saved) {
// 用既有快照初始化,資料完整還原
db = new SQL.Database(saved);
console.log('資料庫已從 IndexedDB 還原');
} else {
// 第一次使用,建立新資料庫
db = new SQL.Database();
db.run(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
`);
console.log('建立新資料庫');
}
return db;
}
|
new SQL.Database(saved) 接受一個 Uint8Array 作為初始資料,這就是整個還原機制的核心。
自動儲存策略
手動在每次操作後呼叫 saveDatabase 很容易忘記,我通常會組合以下幾種策略:
操作後立即儲存——適合低頻操作,像是新增或刪除一筆記錄:
1
2
3
4
5
| async function addTodo(db, text) {
db.run('INSERT INTO todos (text) VALUES (?)', [text]);
// 操作完成後立刻持久化
await saveDatabase(db.export());
}
|
定時儲存——適合高頻操作(如批次寫入),避免每次操作都觸發 IndexedDB 寫入:
1
2
3
4
| // 每 30 秒自動儲存一次
setInterval(async () => {
await saveDatabase(db.export());
}, 30_000);
|
beforeunload 儲存——作為最後一道防線,在使用者關閉分頁前強制存一次:
1
2
3
4
5
6
| window.addEventListener('beforeunload', () => {
// 注意:beforeunload 裡不能用 async/await
// saveDatabase 內部的 IndexedDB 操作是非同步的,
// 瀏覽器不保證在分頁關閉前完成,但實務上通常沒問題
saveDatabase(db.export());
});
|
實務上我會把三者都加上,beforeunload 是補底用的,主要依賴操作後存或定時存。
完整範例:Todo App
把上面的部分組合成一個可以運作的 Todo App:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
| <!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<title>離線 Todo App</title>
</head>
<body>
<h1>Todo</h1>
<input id="input" type="text" placeholder="新增項目..." />
<button id="add-btn">新增</button>
<ul id="list"></ul>
<script type="module">
import initSqlJs from 'https://cdn.jsdelivr.net/npm/sql.js@1.14.0/dist/sql-wasm.js';
import { loadDatabase, saveDatabase } from './db-storage.js';
const SQL = await initSqlJs({
locateFile: (file) =>
`https://cdn.jsdelivr.net/npm/sql.js@1.14.0/dist/${file}`,
});
// 初始化資料庫(還原或新建)
const saved = await loadDatabase();
const db = saved ? new SQL.Database(saved) : new SQL.Database();
if (!saved) {
db.run(`
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)
`);
}
// 渲染清單
function renderList() {
const list = document.getElementById('list');
list.innerHTML = '';
const results = db.exec('SELECT id, text, done FROM todos ORDER BY id');
if (!results.length) return;
for (const [id, text, done] of results[0].values) {
const li = document.createElement('li');
li.textContent = `${done ? '✓' : '○'} ${text}`;
li.style.cursor = 'pointer';
li.addEventListener('click', async () => {
// 切換完成狀態
db.run('UPDATE todos SET done = ? WHERE id = ?', [done ? 0 : 1, id]);
await saveDatabase(db.export()); // 操作後立即存
renderList();
});
list.appendChild(li);
}
}
// 新增 Todo
document.getElementById('add-btn').addEventListener('click', async () => {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
db.run('INSERT INTO todos (text) VALUES (?)', [text]);
await saveDatabase(db.export()); // 操作後立即存
input.value = '';
renderList();
});
// 關閉前補存一次
window.addEventListener('beforeunload', () => {
saveDatabase(db.export());
});
renderList();
</script>
</body>
</html>
|
打開瀏覽器、新增幾筆 Todo、重新整理頁面,資料應該還在。第一次做到這一步的時候我覺得還挺有趣的——一個完全不需要後端的資料應用。
效能注意事項
這套方案在資料庫不大的情況下很好用,但有幾個邊界要留意:
db.export() 是全量快照。不管你只改了一筆資料,它都會把整個資料庫序列化成 Uint8Array。資料庫很小(幾 MB 以內)時感覺不到,但如果資料量長到幾十 MB,每次操作後存一次就會變得很慢。超過 50MB 的資料庫,建議改成只在特定時機(如使用者明確儲存、定時快照)才寫入 IndexedDB,避免頻繁的大型二進位寫入拖慢 UI。
IndexedDB 本身也有配額限制,各瀏覽器和作業系統的上限不一樣,通常是可用磁碟空間的某個百分比。一般應用不會碰到,但如果你預期資料量很大,最好加上配額檢查(navigator.storage.estimate())。
如果你還沒看過 sql.js 的基本用法,可以先看 sql.js 在瀏覽器跑 SQLite 的入門教學。如果你對瀏覽器儲存機制的整體比較感興趣(localStorage、sessionStorage、IndexedDB、Cache API),可以參考 瀏覽器儲存方案全比較。