因為 IndexedDB 的原生寫法太過古老繁瑣且難以維護,因此實作中會使用 Promise版的IndexedDB。
IndexedDB 是很久以前就有的語法,採用 「發起請求 → 監聽事件」 的方式運作。
如果直接用原生語法寫,光是打開資料庫、讀取一筆資料,程式碼會是過深的巢狀,資料庫邏輯很難獨立拆分出來,因此接下來會使用Promise版。
在現代 JavaScript 中,習慣使用 Promise 與 async / await,讓非同步的程式碼讀起來就像一般的同步程式碼一樣直覺:
// Promise 版寫法
const tasks = await getAllTasksFromDB();
console.log("拿到的資料:", tasks);
所謂「封裝成 Promise 版」,就是寫一個包裝函式(Helper),把原生繁瑣的事件監聽(onsuccess / onerror)包在 Promise 的 resolve() 與 reject() 裡面。
這樣一來,只要簡單用 await 就能輕鬆跟資料庫溝通。
在開始寫程式之前,先規劃好瀏覽器資料庫裡的藍圖:
資料庫名稱(Database Name):TaskTimerDB
版本號(Version):1
資料表名稱(Object Store):tasks
主鍵(Key Path):id(以每個主任務的 id 為唯一識別碼)
db.js 封裝模組為了保持程式碼結構清晰,另外建立一個獨立的 db.js 檔案,專門管理 IndexedDB 的連線與資料操作。
openDB)首先設定資料庫名稱、版本與資料表(Object Store)名稱,並實作 openDB() 函式。這裡使用單例模式(Singleton)暫存連線,避免重複觸發開啟資料庫的操作。
// ==========================================
// TaskTimer - IndexedDB 封裝模組 (db.js)
// ==========================================
const DB_NAME = "TaskTimerDB";
const DB_VERSION = 1;
const STORE_NAME = "tasks";
let dbInstance = null; // 暫存開啟後的資料庫連線,避免重複開啟
/**
* 1. 開啟資料庫 (Open Database)
*/
function openDB() {
return new Promise((resolve, reject) => {
// 如果已經連線過,直接回傳連線,不用重複開啟
if (dbInstance) {
resolve(dbInstance);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
// 首次建立資料庫或升級版本時觸發(用來建表)
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
// 建立名為 tasks 的資料表,並以 'id' 當作主鍵
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
// 成功開啟資料庫
request.onsuccess = (event) => {
dbInstance = event.target.result;
resolve(dbInstance); // 代表成功完成,回傳 db 連線
};
// 開啟失敗
request.onerror = (event) => {
console.error("IndexedDB 開啟失敗:", event.target.error);
reject(event.target.error);
};
});
}
重點解析:
onupgradeneeded:只有在資料庫首次建立,或 DB_VERSION 升級時才會觸發,是建立 Object Store 的唯一時機。
keyPath: "id":指定每筆任務物件中的 id 欄位作為唯一主鍵。
getAllTasksFromDB)接著封裝讀取邏輯。因為 IndexedDB 是非同步操作,我們透過 await openDB() 拿到連線後,開啟唯讀交易(readonly)並回傳 Promise 物件。
/**
* 2. 讀取所有任務資料 (Read)
*/
async function getAllTasksFromDB() {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, "readonly");
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = (e) => reject(e.target.error);
});
}
重點解析:
store.getAll():直接撈出 tasks 資料表內的所有資料。如果資料庫還沒有任何資料,則回傳空陣列 [] 作為預設值。saveAllTasksToDB)最後是同步最新資料到資料庫的函式。這裡採用「全量覆寫」策略:先清空整個 Object Store,再將畫面上最新狀態的任務陣列批次寫入。
/**
* 3. 儲存/更新所有任務資料 (Save / Update)
* 當畫面的 tasks 陣列有變動時,呼叫此函式將最新資料寫入瀏覽器
*/
async function saveAllTasksToDB(tasksArray) {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, "readwrite");
const store = transaction.objectStore(STORE_NAME);
// 先清空舊資料,再寫入最新的整包 tasks 陣列
const clearRequest = store.clear();
clearRequest.onsuccess = () => {
tasksArray.forEach((task) => {
store.put(task); // put 會自動新增或覆蓋
});
};
// 整筆交易成功完成時觸發
transaction.oncomplete = () => resolve(true);
transaction.onerror = (e) => reject(e.target.error);
});
}
重點解析:
readwrite 交易類型:執行寫入、更新與刪除時必須宣告為 readwrite。
transaction.oncomplete:IndexedDB 的交易會在所有寫入操作完成後觸發 oncomplete,在這裡 resolve(true) 才能確保資料已真正寫入磁碟。
使用簡潔的函式名稱:把所有底層複雜的交易(Transaction)與事件監聽通通藏在 db.js 內部,對外只看得見簡潔的函式名稱。
單例模式(Singleton):利用 dbInstance 記錄連線狀態,不用每次存資料都重新開啟一次資料庫,執行效能更好。
完美對接 TaskTimer 的全域 State:因為 TaskTimer 採用單一 tasks 陣列驅動畫面,所以提供 saveAllTasksToDB() 可以安心地將整包 State 直接同步存進瀏覽器。
(參考資料:Mdn - Using IndexedDB)