ข้ามไปยังเนื้อหา

การเก็บข้อมูล Offline ด้วย IndexedDB

browser มีกลไก client-side storage หลักสามแบบ แต่ละแบบออกแบบสำหรับงานที่แตกต่างกัน การเลือกแบบที่ถูกต้องจะป้องกัน bugs ที่ลึกซึ้งและทำให้ประสบการณ์ offline ของคุณเชื่อถือได้

localStorageCache StorageIndexedDB
เก็บKey/value stringsHTTP request/response pairsStructured objects (ค่า JS ใดก็ได้)
Async?ไม่ (synchronous, บล็อก main thread)ใช่ (Promise-based)ใช่ (event-based / Promise wrappers)
Query ได้?ไม่ตาม URL เท่านั้นใช่ — indexes, key ranges, cursors
ขีดจำกัดขนาด~5 MBQuota-based (~หลายร้อย MB)Quota-based (~หลายร้อย MB)
ใช้ใน SW ได้?ไม่ใช่ใช่
เหมาะสำหรับการตั้งค่าขนาดเล็ก / flagsCache network resourcesข้อมูลแอป: tasks, messages, user records

หลักการง่าย ๆ: ใช้ Cache Storage สำหรับ network assets ที่ service worker cache (HTML, CSS, JS, รูปภาพ) และใช้ IndexedDB สำหรับข้อมูลจริงที่แอปของคุณทำงานด้วย

IndexedDB เป็น transactional, object-oriented database ที่ built-in ในทุก browser สมัยใหม่ โดยเก็บ JavaScript objects ใน named object stores ที่ระบุด้วย key path ที่คุณเลือก

const request = indexedDB.open('my-app-db', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create an object store keyed on the 'id' property
if (!db.objectStoreNames.contains('tasks')) {
db.createObjectStore('tasks', { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
const db = event.target.result;
console.log('Database opened:', db.name);
};
request.onerror = (event) => {
console.error('Failed to open database:', event.target.error);
};

indexedDB.open(name, version) คืนค่า IDBOpenDBRequest callback onupgradeneeded จะ fire เมื่อใดก็ตามที่ version number เพิ่มขึ้น — นี่เป็นที่เดียวที่คุณสามารถสร้างหรือแก้ไข object stores และ indexes ได้

เมื่อมี db reference แล้ว ให้ wrap ทุก operation ในการ transaction ส่ง store name และ mode — 'readwrite' เพื่อเขียน, 'readonly' เพื่ออ่าน

function saveTask(db, task) {
return new Promise((resolve, reject) => {
const tx = db.transaction('tasks', 'readwrite');
const store = tx.objectStore('tasks');
const putRequest = store.put(task); // insert or replace by keyPath
putRequest.onsuccess = () => resolve(putRequest.result);
putRequest.onerror = () => reject(putRequest.error);
});
}
// Usage
saveTask(db, { id: 'task-1', title: 'Buy groceries', done: false });

store.put(record) แทรก object ถ้า key ไม่มีอยู่ หรือแทนที่ถ้ามีอยู่แล้ว ใช้ store.add(record) ถ้าต้องการ error เมื่อ key ซ้ำกันแทน

function getTask(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction('tasks', 'readonly');
const store = tx.objectStore('tasks');
const getRequest = store.get(id);
getRequest.onsuccess = () => resolve(getRequest.result); // undefined if not found
getRequest.onerror = () => reject(getRequest.error);
});
}
// Usage
getTask(db, 'task-1').then((task) => console.log(task));

เพื่ออ่านทุก record ให้เปิด cursor บน store

function getAllTasks(db) {
return new Promise((resolve, reject) => {
const tx = db.transaction('tasks', 'readonly');
const store = tx.objectStore('tasks');
const results = [];
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
cursorRequest.onerror = () => reject(cursorRequest.error);
});
}

argument ที่สองของ indexedDB.open() คือ schema version ทุกครั้งที่คุณเพิ่มเลขเวอร์ชัน onupgradeneeded จะ fire โดยมี event.oldVersion และ event.newVersion พร้อมใช้งาน ทำให้คุณสามารถ migrate schema แบบ incremental ได้

request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
db.createObjectStore('tasks', { keyPath: 'id' });
}
if (oldVersion < 2) {
// Version 2 adds a 'notes' store
db.createObjectStore('notes', { keyPath: 'id' });
}
};

อย่าลบ store หรือ index โดยไม่ตรวจสอบ oldVersion — ผู้ใช้อาจ upgrade จาก version ก่อนหน้าใด ๆ ไม่ใช่เฉพาะ version ก่อนหน้าเท่านั้น

Raw event-based API มีความยืดยาว ใน production ทีมส่วนใหญ่ใช้ thin wrapper เช่น library idb (ขนาดไม่กี่ KB) ที่นำเสนอ API เดียวกันในรูปแบบ Promise chains แนวคิดเหมือนกัน — open, transaction, store, put/get — wrapper เพียงแค่ลด boilerplate event handlers

sequenceDiagram
  participant App as Page / SW
  participant IDB as IndexedDB

  App->>IDB: indexedDB.open('my-app-db', 1)
  IDB-->>App: onupgradeneeded (create stores)
  IDB-->>App: onsuccess (db handle)
  App->>IDB: db.transaction('tasks', 'readwrite')
  App->>IDB: store.put(task)
  IDB-->>App: onsuccess (key)
  App->>IDB: store.get('task-1')
  IDB-->>App: onsuccess (record)
IndexedDB open, write, and read sequence
ตัวเลือกBenefitCost
เก็บ mutation/data offline ใน IndexedDBทนทาน รอด service worker terminate และ browser restart ได้API ใช้งานซับซ้อนกว่า localStorage มาก ต้อง handle transaction/schema เอง
เก็บชั่วคราวใน memory ของ service workerเขียนโค้ดง่ายกว่า เร็วกว่าในระยะสั้นหายทันทีที่ service worker ถูก browser terminate (เกิดบ่อยเมื่อ idle)
  • ทำ mutation ระหว่าง offline ไว้ใน variable ธรรมดาแทน IndexedDB ทำให้ข้อมูลหายเมื่อ service worker ถูก kill
  • ไม่ handle onupgradeneeded ให้ครบ ทำให้ schema migration พังตอนอัปเดต version ของ database
  • ไม่มี error handler บน transaction ทำให้ error เงียบหายไปโดยไม่มีใครรู้ว่า write ล้มเหลว

💡 ตัวอย่างจากของจริง

Google Docs offline — เก็บการแก้ไขเอกสารระหว่าง offline ไว้ใน IndexedDB แล้ว sync กลับ server ทันทีที่กลับมามีเน็ต

Gmail — cache อีเมลและ draft ไว้ใน IndexedDB ทำให้เปิดอ่าน/แก้ draft ได้แม้ไม่มีสัญญาณอินเทอร์เน็ต

storage API ของ browser ตัวใดที่ใช้ได้ภายใน service worker?
callback onupgradeneeded ทำงานเมื่อใด?
store.put() กับ store.add() ต่างกันอย่างไร?
ควรเลือก storage ตัวใดในการ cache JavaScript bundle เพื่อให้โหลดได้แบบ offline?