TypeScript และ IndexedDB
บทความนี้อธิบายเกี่ยวกับ TypeScript และ IndexedDB
เราจะอธิบายเกี่ยวกับ TypeScript และ IndexedDB พร้อมตัวอย่างที่ใช้งานจริง
YouTube Video
TypeScript และ IndexedDB
IndexedDB เป็นที่เก็บข้อมูล NoSQL ระดับต่ำที่ช่วยให้คุณจัดเก็บข้อมูลที่มีโครงสร้างไว้ในเบราว์เซอร์ได้ ด้วย TypeScript คุณสามารถระบุ schema ได้อย่างปลอดภัย ลดข้อผิดพลาด และช่วยให้บำรุงรักษาง่ายขึ้น
คำศัพท์พื้นฐานและกระบวนการทำงาน
IndexedDB เป็นฐานข้อมูลขนาดเล็กภายในเบราว์เซอร์ มันจัดการข้อมูลโดยใช้กลไกต่างๆ เช่น ฐานข้อมูลที่มีชื่อและเวอร์ชัน ร้านเก็บวัตถุ ธุรกรรม ดัชนี และเคอร์เซอร์ ฐานข้อมูลมีเวอร์ชัน และเมื่อมีการอัปเกรดเวอร์ชัน จะมีการเรียก onupgradeneeded เพื่อปรับปรุงสคีมา เช่น สร้างหรือแก้ไขตาราง
การเปิด IndexedDB (แพทเทิร์นพื้นฐาน)
ก่อนอื่น จะแสดงตัวอย่างการเปิดฐานข้อมูลด้วย IndexedDB และสร้าง object store ใน onupgradeneeded หากจำเป็น
1// Open an IndexedDB database and create an object store if needed.
2// This code shows the classic callback-based IndexedDB API wrapped into a Promise.
3function openDatabase(dbName: string, version: number): Promise<IDBDatabase> {
4 return new Promise((resolve, reject) => {
5 const request = indexedDB.open(dbName, version);
6
7 request.onerror = () => {
8 reject(request.error);
9 };
10
11 request.onupgradeneeded = (event) => {
12 const db = request.result;
13 if (!db.objectStoreNames.contains('todos')) {
14 // Create object store with keyPath 'id'
15 db.createObjectStore('todos', { keyPath: 'id' });
16 }
17 };
18
19 request.onsuccess = () => {
20 resolve(request.result);
21 };
22 });
23}
24
25// Usage example:
26openDatabase('my-db', 1)
27 .then(db => {
28 console.log('DB opened', db.name, db.version);
29 db.close();
30 })
31 .catch(err => console.error('Failed to open DB', err));- โค้ดนี้เปิดฐานข้อมูลและบันทึกว่าการดำเนินการสำเร็จหรือล้มเหลว
- หากจำเป็น จะสร้าง store ชื่อ
todosในonupgradeneeded
การกำหนดประเภทข้อมูลใน TypeScript (Model)
ต่อไป เราจะกำหนดประเภทข้อมูลโดยใช้ TypeScript สิ่งนี้ช่วยให้มั่นใจเรื่องความปลอดภัยของชนิดข้อมูลใน CRUD operation ถัดไป
1// Define the TypeScript interface for a Todo item.
2// This helps with type safety in the database operations.
3interface Todo {
4 id: string; // primary key
5 title: string;
6 completed: boolean;
7 createdAt: number;
8}- ที่นี่ เรากำหนด type ชื่อ
Todo
ตัวอย่างการเขียนฟังก์ชัน CRUD แบบง่าย
ต่อไปจะแสดงการดำเนินการ CRUD พื้นฐาน เช่น เพิ่ม, ดึงข้อมูล, อัปเดต และลบข้อมูลจาก object store แต่ละฟังก์ชันจะรับ IDBDatabase และส่งคืน Promise
1// CRUD utilities for the 'todos' object store.
2// Each operation uses a transaction and returns a Promise for easier async/await usage.
3
4function addTodo(db: IDBDatabase, todo: Todo): Promise<void> {
5 return new Promise((resolve, reject) => {
6 const tx = db.transaction('todos', 'readwrite');
7 const store = tx.objectStore('todos');
8 const req = store.add(todo);
9
10 req.onsuccess = () => {
11 console.log('Todo added', todo.id);
12 };
13 req.onerror = () => reject(req.error);
14
15 tx.oncomplete = () => resolve();
16 tx.onerror = () => reject(tx.error);
17 });
18}- ฟังก์ชันนี้เพิ่ม
Todoใหม่ในที่เก็บtodosของ IndexedDB มันส่งคืน Promise สำหรับการจัดการแบบอะซิงโครนัส และจะเสร็จสมบูรณ์เมื่อประมวลผลเสร็จ
1function getTodo(db: IDBDatabase, id: string): Promise<Todo | undefined> {
2 return new Promise((resolve, reject) => {
3 const tx = db.transaction('todos', 'readonly');
4 const store = tx.objectStore('todos');
5 const req = store.get(id);
6
7 req.onsuccess = () => resolve(req.result as Todo | undefined);
8 req.onerror = () => reject(req.error);
9 });
10}- ฟังก์ชันนี้ดึง
Todoที่มีรหัสที่กำหนด และส่งคืนวัตถุหากพบ หากไม่พบข้อมูลที่ต้องการ จะคืนค่าundefined
1function updateTodo(db: IDBDatabase, todo: Todo): Promise<void> {
2 return new Promise((resolve, reject) => {
3 const tx = db.transaction('todos', 'readwrite');
4 const store = tx.objectStore('todos');
5 const req = store.put(todo);
6
7 req.onsuccess = () => {
8 console.log('Todo updated', todo.id);
9 };
10 req.onerror = () => reject(req.error);
11
12 tx.oncomplete = () => resolve();
13 tx.onerror = () => reject(tx.error);
14 });
15}- ฟังก์ชันนี้อัปเดตข้อมูล
Todoที่มีอยู่ เมื่อสำเร็จ จะมีการบันทึก ID ของTodoที่อัปเดต
1function deleteTodo(db: IDBDatabase, id: string): Promise<void> {
2 return new Promise((resolve, reject) => {
3 const tx = db.transaction('todos', 'readwrite');
4 const store = tx.objectStore('todos');
5 const req = store.delete(id);
6
7 req.onsuccess = () => {
8 console.log('Todo deleted', id);
9 };
10 req.onerror = () => reject(req.error);
11
12 tx.oncomplete = () => resolve();
13 tx.onerror = () => reject(tx.error);
14 });
15}-
ฟังก์ชันนี้ลบ
Todoที่มีรหัสที่ระบุ เมื่อลบเสร็จ จะ log ID ที่ถูกลบ -
ฟังก์ชันเหล่านี้จะ resolve หรือ reject Promise ตามว่าธุรกรรมนั้นเสร็จสมบูรณ์หรือมีข้อผิดพลาด การใช้
console.logช่วยให้สามารถติดตามสิ่งที่เกิดขึ้นระหว่างการทำงานได้ง่าย
การใช้ Index และคิวรีแบบซับซ้อน
โดยการใช้ index ใน IndexedDB คุณสามารถค้นหาข้อมูลในฟิลด์ที่ต้องการได้อย่างมีประสิทธิภาพ ที่นี่ เราสร้าง index สำหรับ createdAt และยกตัวอย่างการคิวรีแบบช่วง
1// When opening DB, create an index for createdAt.
2// Then demonstrate a range query using the index.
3
4function openDatabaseWithIndex(dbName: string, version: number): Promise<IDBDatabase> {
5 return new Promise((resolve, reject) => {
6 const request = indexedDB.open(dbName, version);
7
8 request.onupgradeneeded = () => {
9 const db = request.result;
10 if (!db.objectStoreNames.contains('todos')) {
11 const store = db.createObjectStore('todos', { keyPath: 'id' });
12 // Create an index on createdAt for sorting/filtering
13 store.createIndex('by-createdAt', 'createdAt', { unique: false });
14 } else {
15 const store = request.transaction!.objectStore('todos');
16 if (!store.indexNames.contains('by-createdAt')) {
17 store.createIndex('by-createdAt', 'createdAt', { unique: false });
18 }
19 }
20 };
21
22 request.onerror = () => reject(request.error);
23 request.onsuccess = () => resolve(request.result);
24 });
25}- ฟังก์ชันนี้จะเปิดฐานข้อมูลและสร้างหรือยืนยัน index ชื่อ
by-createdAtในฟิลด์createdAtสิ่งนี้ช่วยให้สามารถค้นหาและจัดเรียงตามวันสร้างได้อย่างมีประสิทธิภาพ
1async function getTodosCreatedAfter(db: IDBDatabase, timestamp: number): Promise<Todo[]> {
2 return new Promise((resolve, reject) => {
3 const tx = db.transaction('todos', 'readonly');
4 const store = tx.objectStore('todos');
5 const index = store.index('by-createdAt');
6 const range = IDBKeyRange.lowerBound(timestamp, true); // exclusive
7 const req = index.openCursor(range);
8
9 const results: Todo[] = [];
10 req.onsuccess = (event) => {
11 const cursor = (event.target as IDBRequest).result as IDBCursorWithValue | null;
12 if (cursor) {
13 results.push(cursor.value as Todo);
14 cursor.continue();
15 } else {
16 resolve(results);
17 }
18 };
19 req.onerror = () => reject(req.error);
20 });
21}-
ฟังก์ชันนี้ดึงเฉพาะ
Todoที่ถูกสร้างหลังเวลาที่ระบุไว้ การใช้ index ช่วยให้สามารถสแกนข้อมูลตามลำดับวันที่สร้างได้อย่างรวดเร็ว -
ในตัวอย่างนี้ จะมีการสร้างดัชนี
by-createdAtระหว่างการอัปเกรดฐานข้อมูล และใช้เคอร์เซอร์เพื่อดึงรายการTodoที่ถูกสร้างหลังเวลาที่กำหนด
คลาส wrapper น้ำหนักเบาที่ใช้ Promise
API ระดับล่างของ IndexedDB นั้นเขียนได้ซับซ้อน และการดำเนินการที่คล้ายกันซ้ำ ๆ อาจทำให้เกิดความซ้ำซ้อนและข้อบกพร่อง ดังนั้น การเตรียมคลาสห่อหุ้ม (wrapper) แบบทั่วไปของ TypeScript ที่สรุปกระบวนการต่างๆ จะช่วยให้โค้ดเรียบง่ายขึ้นและดูแลรักษาง่ายขึ้น ด้านล่างคือตัวอย่างการใช้งานที่มุ่งเน้นฟังก์ชันพื้นฐาน
1// A minimal TypeScript wrapper around IndexedDB to simplify common operations.
2// This class is generic over the store's value type and assumes 'keyPath' is 'id'.
3
4class IDBWrapper<T extends { id: string }> {
5 private dbPromise: Promise<IDBDatabase>;
6
7 constructor(private dbName: string, private version: number, private storeName: string) {
8 this.dbPromise = this.open();
9 }- คลาสนี้จะ wrap การทำงานกับ IndexedDB และให้ method CRUD ที่ปลอดภัยในเรื่องชนิดข้อมูล โดยสมมติว่า object store มีคีย์เป็น
id
1 private open(): Promise<IDBDatabase> {
2 return new Promise((resolve, reject) => {
3 const req = indexedDB.open(this.dbName, this.version);
4 req.onerror = () => reject(req.error);
5 req.onupgradeneeded = () => {
6 const db = req.result;
7 if (!db.objectStoreNames.contains(this.storeName)) {
8 db.createObjectStore(this.storeName, { keyPath: 'id' });
9 }
10 };
11 req.onsuccess = () => resolve(req.result);
12 });
13 }- มันจะเปิดฐานข้อมูลและสร้าง object store ใหม่หากจำเป็น การกำหนดค่าเริ่มต้นของ store ทำในช่วง upgrade event
1 async add(item: T): Promise<void> {
2 const db = await this.dbPromise;
3 await new Promise<void>((resolve, reject) => {
4 const tx = db.transaction(this.storeName, 'readwrite');
5 const store = tx.objectStore(this.storeName);
6 const req = store.add(item);
7 req.onsuccess = () => {
8 console.log('added', item.id);
9 };
10 req.onerror = () => reject(req.error);
11 tx.oncomplete = () => resolve();
12 tx.onerror = () => reject(tx.error);
13 });
14 }- เพิ่มข้อมูลเข้า store ของ IndexedDB หลังเพิ่มข้อมูลจะ log ID ใน console
1 async get(id: string): Promise<T | undefined> {
2 const db = await this.dbPromise;
3 return new Promise((resolve, reject) => {
4 const tx = db.transaction(this.storeName, 'readonly');
5 const store = tx.objectStore(this.storeName);
6 const req = store.get(id);
7 req.onsuccess = () => resolve(req.result as T | undefined);
8 req.onerror = () => reject(req.error);
9 });
10 }- ดึงข้อมูลตาม ID ที่กำหนด หากไม่มีข้อมูลจะคืนค่า
undefined
1 async put(item: T): Promise<void> {
2 const db = await this.dbPromise;
3 return new Promise((resolve, reject) => {
4 const tx = db.transaction(this.storeName, 'readwrite');
5 const store = tx.objectStore(this.storeName);
6 const req = store.put(item);
7 req.onsuccess = () => {
8 console.log('put', item.id);
9 };
10 req.onerror = () => reject(req.error);
11 tx.oncomplete = () => resolve();
12 tx.onerror = () => reject(tx.error);
13 });
14 }- อัปเดตข้อมูลที่มีหรือเพิ่มข้อมูลใหม่ หลังประมวลผลจะ log ID ที่อัปเดต
1 async delete(id: string): Promise<void> {
2 const db = await this.dbPromise;
3 return new Promise((resolve, reject) => {
4 const tx = db.transaction(this.storeName, 'readwrite');
5 const store = tx.objectStore(this.storeName);
6 const req = store.delete(id);
7 req.onsuccess = () => {
8 console.log('deleted', id);
9 };
10 req.onerror = () => reject(req.error);
11 tx.oncomplete = () => resolve();
12 tx.onerror = () => reject(tx.error);
13 });
14 }- ลบข้อมูลที่มี ID ตามที่กำหนด หากสำเร็จจะ log ID ที่ถูกลบ
1 async getAll(): Promise<T[]> {
2 const db = await this.dbPromise;
3 return new Promise((resolve, reject) => {
4 const tx = db.transaction(this.storeName, 'readonly');
5 const store = tx.objectStore(this.storeName);
6 const req = store.getAll();
7 req.onsuccess = () => resolve(req.result as T[]);
8 req.onerror = () => reject(req.error);
9 });
10 }
11}- ดึงข้อมูลทั้งหมดจาก store ค่าที่ return กลับเป็น array ของชนิด
T
1// Example usage with Todo type:
2interface Todo {
3 id: string;
4 title: string;
5 completed: boolean;
6 createdAt: number;
7}
8
9const todoStore = new IDBWrapper<Todo>('my-db', 1, 'todos');
10
11(async () => {
12 const newTodo: Todo = { id: '1', title: 'Learn IndexedDB', completed: false, createdAt: Date.now() };
13 await todoStore.add(newTodo);
14 const fetched = await todoStore.get('1');
15 console.log('fetched', fetched);
16 newTodo.completed = true;
17 await todoStore.put(newTodo);
18 const all = await todoStore.getAll();
19 console.log('all todos', all);
20 await todoStore.delete('1');
21})();-
โค้ดนี้เป็นตัวอย่างการใช้คลาส
IDBWrapperจริง นี่แสดงขั้นตอนการเพิ่ม ดึง อัปเดต แสดงรายการ และลบข้อมูลTodo -
คลาส wrapper นี้ช่วยให้สามารถใช้ CRUD ได้อย่างง่ายดาย ในสภาพแวดล้อมจริง คุณต้องจัดการกับการจัดการข้อผิดพลาดและการจัดการสคีมา (ดัชนี) ด้วย
การย้ายข้อมูล schema (อัปเกรดเวอร์ชัน)
หากต้องการเปลี่ยน schema ของฐานข้อมูล ให้เพิ่มอาร์กิวเมนต์ตัวที่สอง (เวอร์ชัน) ของ indexedDB.open และอัปเดตใน onupgradeneeded คุณต้องออกแบบให้ธุรกรรมที่มีอยู่เสร็จสมบูรณ์และหลีกเลี่ยงการเปลี่ยนแปลงที่อาจทำลายข้อมูลเดิม
1// Example of handling upgrade to version 2: add an index and perhaps migrate data.
2// onupgradeneeded receives an event where oldVersion and newVersion are accessible.
3
4function upgradeToV2(dbName: string): Promise<IDBDatabase> {
5 return new Promise((resolve, reject) => {
6 const req = indexedDB.open(dbName, 2);
7 req.onupgradeneeded = (ev) => {
8 const db = req.result;
9 const oldVersion = (ev as IDBVersionChangeEvent).oldVersion;
10 console.log('Upgrading DB from', oldVersion, 'to', db.version);
11 let store: IDBObjectStore;
12 if (!db.objectStoreNames.contains('todos')) {
13 store = db.createObjectStore('todos', { keyPath: 'id' });
14 } else {
15 store = req.transaction!.objectStore('todos');
16 }
17 // Add index if not present
18 if (!store.indexNames.contains('by-completed')) {
19 store.createIndex('by-completed', 'completed', { unique: false });
20 }
21
22 // Optional: data migration logic if necessary can go here,
23 // but heavy migrations often should be done lazily on read.
24 };
25 req.onsuccess = () => resolve(req.result);
26 req.onerror = () => reject(req.error);
27 });
28}- การประมวลผลหนักใน
onupgradeneededอาจทำให้ UI ค้าง ควรลดให้น้อยที่สุด และหากจำเป็นให้พิจารณาทำ migration แบบ delayed (ประมวลผลขณะเริ่มแอป)
ข้อควรระวังเกี่ยวกับธุรกรรม (วงจรชีวิตและข้อผิดพลาด)
ธุรกรรมจะถูก commit โดยอัตโนมัติก่อนที่สคริปต์ที่สร้างมันจะจบการทำงาน การใช้ await ในธุรกรรมเดียวกันอาจทำให้ธุรกรรมถูก commit โดยไม่คาดคิด ควรระวังหากต้องจัดการหลาย async operation ใน transaction เดียวกัน
1// Bad pattern: awaiting outside transaction callbacks can cause tx to auto-commit.
2// Good pattern is to chain requests and resolve on tx.oncomplete as shown earlier.
3
4// Example: Do multiple operations inside single tx, avoid awaiting inside.
5function multiOperation(db: IDBDatabase, items: Todo[]): Promise<void> {
6 return new Promise((resolve, reject) => {
7 const tx = db.transaction('todos', 'readwrite');
8 const store = tx.objectStore('todos');
9
10 for (const item of items) {
11 const req = store.put(item);
12 req.onerror = () => console.error('put error', req.error);
13 // Do NOT await here; just schedule requests synchronously.
14 }
15
16 tx.oncomplete = () => {
17 console.log('All operations in transaction completed');
18 resolve();
19 };
20 tx.onerror = () => reject(tx.error);
21 });
22}- ต้องทราบอายุของ transaction อาจแยกเป็นหลาย transaction ถ้าจำเป็น หรือเรียกฟังก์ชันแบบ synchronous ภายใน transaction นั้นแทน
การใช้ cursor และการแบ่งหน้าแสดงผล
โดยการใช้ cursor คุณสามารถประมวลผลข้อมูลขนาดใหญ่ทีละลำดับ หรือทำการแบ่งหน้า (pagination) อย่างง่ายโดยไม่ต้องใช้ offset
1// Example: fetch first N items using a cursor (ascending by key).
2function fetchFirstN(db: IDBDatabase, n: number): Promise<Todo[]> {
3 return new Promise((resolve, reject) => {
4 const tx = db.transaction('todos', 'readonly');
5 const store = tx.objectStore('todos');
6 const req = store.openCursor();
7 const out: Todo[] = [];
8 req.onsuccess = (ev) => {
9 const cursor = (ev.target as IDBRequest).result as IDBCursorWithValue | null;
10 if (cursor && out.length < n) {
11 out.push(cursor.value as Todo);
12 cursor.continue();
13 } else {
14 resolve(out);
15 }
16 };
17 req.onerror = () => reject(req.error);
18 });
19}- การดึงข้อมูลทีละรายการด้วย cursor จะช่วยลดการใช้หน่วยความจำ ขณะแบ่งหน้า (paging) มักใช้คีย์ตัวล่าสุดที่อ่านไปในการเรียกข้อมูลรอบถัดไป
การจัดการข้อผิดพลาดและวิธีสำรอง (fallback)
1// Feature detection
2if (!('indexedDB' in window)) {
3 console.warn('IndexedDB is not supported. Falling back to localStorage.');
4 // implement fallback logic...
5}- IndexedDB อาจไม่สามารถใช้งานได้เนื่องจากความแตกต่างในการนำไปใช้ของแต่ละเบราว์เซอร์หรือการตั้งค่าความเป็นส่วนตัวของผู้ใช้ เช่น การท่องเว็บแบบส่วนตัว ดังนั้น ควรตรวจสอบว่า
indexedDBมีอยู่หรือไม่ และหากไม่มี ให้จัดเตรียมตัวเลือกสำรองเช่นlocalStorage
ประสิทธิภาพและแนวทางปฏิบัติที่ดี
IndexedDB เร็วและทรงพลัง แต่ประสิทธิภาพอาจแตกต่างกันมากขึ้นอยู่กับการออกแบบและการจัดการข้อมูล ขึ้นอยู่กับกรณีการใช้งาน สามารถเพิ่มประสิทธิภาพได้ดังนี้:
- ออกแบบที่เก็บวัตถุตามการใช้งานจริง ตัวอย่างเช่น หากมีการอ่านข้อมูลมาก ควรมีดัชนี หากมีการเขียนข้อมูลมาก ควรออกแบบคีย์ให้เรียบง่าย
- ข้อมูลไบนารีขนาดใหญ่ เช่น รูปภาพและเสียง ควรเก็บเป็น Blob หรือจัดการผ่าน File API หรือ service workers หากจำเป็น หากจำเป็นก็สามารถใช้การบีบอัดข้อมูลได้
- ควรทำธุรกรรมให้สั้นที่สุดและทำกระบวนการที่ใช้ทรัพยากรมากนอกธุรกรรม เพื่อลดเวลาการล็อก
- ดัชนีช่วยให้การค้นหาข้อมูลเร็วขึ้น แต่ทำให้การแทรกและอัปเดตช้าลง ดังนั้นควรสร้างเฉพาะดัชนีที่จำเป็นจริงๆ
- เมื่อจัดการข้อมูลชิ้นเล็กๆ เป็นจำนวนมาก การดึงทั้งหมดพร้อมกันด้วย
getAll()อาจใช้หน่วยความจำมากเกินไป คุณสามารถลดการใช้หน่วยความจำโดยแบ่งการประมวลผลด้วยเคอร์เซอร์
ความปลอดภัยและความเป็นส่วนตัว
ข้อมูลใน IndexedDB แยกออกจากกันตาม domain และ protocol ตามหลัก same-origin policy ออกแบบโดยคาดว่าข้อมูลอาจสูญหายได้หากผู้ใช้ลบข้อมูลเบราว์เซอร์หรือใช้โหมดส่วนตัว
สรุปและแนวทางออกแบบแนะนำ
หากต้องการใช้ IndexedDB ร่วมกับ TypeScript ได้อย่างมีประสิทธิภาพ ควรเตรียมชนิดข้อมูลและกระบวนการแบบอะซิงโครนัสให้พร้อม ใส่ใจเรื่องการจัดการเวอร์ชันและธุรกรรม และห่อหุ้มกระบวนการทั่วไปเพื่อเพิ่มความสามารถในการดูแลรักษาโค้ด
- การกำหนด type ด้วย TypeScript และ wrap การทำงานกับ IndexedDB ด้วย Promise/async/await ช่วยเพิ่มความปลอดภัยและอ่านโค้ดง่ายขึ้น
- การเปลี่ยน schema ควรใช้ version management กับ
onupgradeneededและเลื่อนการประมวลผลหนักหากทำได้ - ออกแบบ transaction ให้สั้น และหลีกเลี่ยงการประมวลผลอะซิงโครนัสหนักๆ ภายใน transaction เดียวกัน
- การสร้างคลาสสำหรับห่อหุ้ม จะช่วยลดขั้นตอนการจัดการข้อผิดพลาด การบันทึก และการกำหนดชนิดข้อมูลที่ซ้ำซ้อน
คุณสามารถติดตามบทความข้างต้นโดยใช้ Visual Studio Code บนช่อง YouTube ของเรา กรุณาตรวจสอบช่อง YouTube ด้วย