TypeScript và IndexedDB

TypeScript và IndexedDB

Bài viết này giải thích về TypeScript và IndexedDB.

Chúng tôi sẽ giải thích TypeScript và IndexedDB với các ví dụ thực tế.

YouTube Video

TypeScript và IndexedDB

IndexedDB là một kho lưu trữ NoSQL cấp thấp cho phép bạn lưu trữ dữ liệu có cấu trúc trong trình duyệt. Với TypeScript, bạn có thể biểu diễn các lược đồ một cách an toàn kiểu, giảm lỗi và nâng cao khả năng bảo trì.

Thuật ngữ cơ bản và Quy trình làm việc

IndexedDB là một cơ sở dữ liệu nhỏ nằm bên trong trình duyệt. Nó quản lý dữ liệu bằng các cơ chế như cơ sở dữ liệu có tên và phiên bản, kho đối tượng, giao dịch, chỉ mục và con trỏ. Cơ sở dữ liệu có phiên bản và khi phiên bản được nâng cấp, onupgradeneeded được gọi để cập nhật schema, như tạo mới hoặc chỉnh sửa bảng.

Mở IndexedDB (mẫu cơ bản)

Đầu tiên, chúng tôi trình bày một ví dụ về việc mở cơ sở dữ liệu bằng IndexedDB và tạo một object store trong onupgradeneeded nếu cần thiết.

 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));
  • Đoạn mã này mở cơ sở dữ liệu và ghi lại việc thành công hay thất bại.
  • Nếu cần, store todos sẽ được tạo trong onupgradeneeded.

Định nghĩa kiểu trong TypeScript (Mô hình)

Tiếp theo, chúng ta định nghĩa các kiểu dữ liệu bằng TypeScript. Việc này đảm bảo an toàn kiểu cho các thao tác CRUD sau này.

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}
  • Ở đây, chúng tôi định nghĩa kiểu Todo.

Các ví dụ triển khai hàm CRUD đơn giản

Tiếp theo, chúng tôi trình bày các thao tác CRUD cơ bản như thêm, lấy, cập nhật và xóa từ object store. Mỗi hàm nhận một IDBDatabase và trả về một 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}
  • Hàm này thêm một Todo mới vào kho todos trong IndexedDB. Nó trả về một Promise để xử lý bất đồng bộ, sẽ hoàn thành khi xử lý xong.
 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}
  • Hàm này lấy Todo với ID được chỉ định và trả về đối tượng nếu tìm thấy. Nếu không tìm thấy dữ liệu phù hợp, nó trả về 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}
  • Hàm này cập nhật dữ liệu Todo hiện có. Khi thành công, ID của Todo đã được cập nhật sẽ được ghi lại.
 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}
  • Hàm này xóa Todo với ID được chỉ định. Khi xử lý thành công, ID đã bị xóa sẽ được ghi lại.

  • Những hàm này sẽ resolve hoặc reject một Promise tùy vào việc giao dịch hoàn tất hoặc gặp lỗi. Việc xuất kết quả bằng console.log giúp dễ dàng theo dõi quá trình thực thi.

Chỉ mục và truy vấn kết hợp

Bằng cách sử dụng chỉ mục trong IndexedDB, bạn có thể tìm kiếm hiệu quả trên các trường cụ thể. Tại đây, chúng tôi tạo chỉ mục cho createdAt và đưa ra ví dụ về truy vấn phạm vi.

 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}
  • Hàm này mở cơ sở dữ liệu và tạo hoặc xác minh chỉ mục by-createdAt trên trường createdAt. Điều này cho phép tìm kiếm và sắp xếp hiệu quả theo ngày tạo.
 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}
  • Hàm này chỉ lấy những Todo được tạo sau thời điểm được chỉ định. Việc sử dụng chỉ mục cho phép quét dữ liệu hiệu quả theo thứ tự ngày tạo.

  • Trong ví dụ này, một chỉ mục by-createdAt được tạo ra khi nâng cấp cơ sở dữ liệu, và các mục Todo được tạo sau thời điểm chỉ định sẽ được liệt kê bởi con trỏ.

Lớp bọc gọn nhẹ dựa trên Promise

API IndexedDB cấp thấp rất phức tạp để viết, và việc lặp đi lặp lại các thao tác tương tự có thể dẫn đến dư thừa và lỗi. Vì vậy, bằng cách chuẩn bị một lớp bao bọc TypeScript tổng quát để trừu tượng hóa các thao tác, chúng ta nâng cao sự đơn giản và khả năng bảo trì của mã nguồn. Dưới đây là một triển khai tập trung vào các chức năng cơ bản.

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  }
  • Lớp này bao bọc các thao tác IndexedDB và cung cấp các phương thức CRUD an toàn kiểu. Nó giả định một object store với khóa là 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  }
  • Nó mở cơ sở dữ liệu và tạo object store mới nếu cần thiết. Khởi tạo store được thực hiện bằng sự kiện nâng cấp.
 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  }
  • Thêm dữ liệu vào store IndexedDB. Sau khi thêm, ID sẽ được ghi ra 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  }
  • Lấy dữ liệu tương ứng với ID đã chỉ định. undefined sẽ được trả về nếu dữ liệu không tồn tại.
 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  }
  • Cập nhật dữ liệu hiện có hoặc thêm dữ liệu mới. Sau khi xử lý, ID được cập nhật sẽ được ghi lại.
 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  }
  • Xóa dữ liệu với ID đã chỉ định. Khi thành công, ID đã xóa sẽ được ghi lại.
 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}
  • Lấy toàn bộ dữ liệu trong store. Giá trị trả về là một mảng kiểu 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})();
  • Đoạn mã này là một ví dụ sử dụng thực tế của lớp IDBWrapper. Điều này cho thấy luồng thao tác thêm, lấy, cập nhật, liệt kê và xóa dữ liệu Todo.

  • Lớp bọc này cho phép xử lý đơn giản các thao tác CRUD cơ bản. Trong môi trường thực tế, bạn cũng cần xử lý các lỗi và quản lý schema (chỉ mục).

Di chuyển lược đồ (nâng cấp phiên bản)

Để thay đổi lược đồ cơ sở dữ liệu, hãy tăng đối số thứ hai (phiên bản) của indexedDB.open và cập nhật nó trong onupgradeneeded. Bạn cần thiết kế sao cho các giao dịch hiện tại đã hoàn thành và tránh các thay đổi phá hủy.

 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}
  • Xử lý nặng trong onupgradeneeded có thể làm treo giao diện, vì vậy hãy tối thiểu hóa, và nếu có thể hãy cân nhắc di chuyển từng phần (xử lý tạm thời khi khởi động ứng dụng).

Lưu ý về giao dịch (vòng đời và lỗi)

Giao dịch được tự động commit trước khi đoạn script tạo ra chúng kết thúc. Khi sử dụng await trong giao dịch, nó có thể được commit một cách bất ngờ; hãy cẩn thận khi thực hiện nhiều thao tác bất đồng bộ trong cùng một giao dịch.

 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}
  • Lưu ý đến vòng đời giao dịch; sử dụng giao dịch riêng nếu cần hoặc sắp xếp các thao tác đồng bộ trong cùng một giao dịch.

Ứng dụng con trỏ và phân trang

Bằng cách sử dụng con trỏ, bạn có thể xử lý dữ liệu quy mô lớn một cách tuần tự hoặc thực hiện phân trang đơn giản mà không cần sử dụng 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}
  • Bằng cách lấy từng mục một với con trỏ, có thể giảm sử dụng bộ nhớ. Khi triển khai phân trang, thông thường bạn sẽ lưu lại khóa cuối đã đọc.

Xử lý lỗi và phương án dự phòng

1// Feature detection
2if (!('indexedDB' in window)) {
3  console.warn('IndexedDB is not supported. Falling back to localStorage.');
4  // implement fallback logic...
5}
  • IndexedDB có thể không khả dụng do sự khác biệt trong cách triển khai giữa các trình duyệt hoặc cài đặt quyền riêng tư của người dùng, chẳng hạn như chế độ duyệt web riêng tư. Do đó, hãy kiểm tra xem indexedDB có tồn tại không, nếu không thì hãy cung cấp một phương án dự phòng như localStorage.

Hiệu năng và các thực tiễn tốt

IndexedDB nhanh và mạnh mẽ, nhưng hiệu suất có thể thay đổi rất nhiều tùy thuộc vào thiết kế và cách xử lý dữ liệu. Tùy vào trường hợp sử dụng, tối ưu hóa có thể được thực hiện theo các cách sau:.

  • Thiết kế kho đối tượng phù hợp với cách sử dụng thực tế. Ví dụ, nếu có nhiều thao tác đọc thì nên tạo thêm chỉ mục; nếu có nhiều thao tác ghi thì nên giữ thiết kế khóa đơn giản.
  • Dữ liệu nhị phân lớn như ảnh và âm thanh nên được lưu dưới dạng Blob, hoặc quản lý bằng File API hoặc service workers nếu cần. Nén dữ liệu cũng có thể được cân nhắc nếu cần thiết.
  • Giữ giao dịch càng ngắn càng tốt, và xử lý các thao tác nặng bên ngoài giao dịch để giảm thời gian khóa.
  • Chỉ mục có thể tăng tốc tìm kiếm nhưng làm chậm việc chèn và cập nhật, vì vậy chỉ nên tạo những chỉ mục thật sự cần thiết.
  • Khi xử lý nhiều dữ liệu nhỏ, lấy tất cả một lần bằng getAll() có thể làm cạn kiệt bộ nhớ. Có thể giảm mức sử dụng bộ nhớ bằng cách chia nhỏ xử lý bằng con trỏ.

Bảo mật và quyền riêng tư

Dữ liệu IndexedDB được cô lập theo từng domain và giao thức dựa trên chính sách cùng nguồn (same-origin policy). Hãy thiết kế dựa trên giả định rằng dữ liệu có thể bị mất nếu người dùng xóa dữ liệu trình duyệt hoặc sử dụng chế độ riêng tư.

Tóm tắt và mẫu thiết kế đề xuất

Để sử dụng IndexedDB hiệu quả với TypeScript, điều quan trọng là chuẩn bị các kiểu dữ liệu, quy trình bất đồng bộ, chú ý quản lý phiên bản và thiết kế giao dịch, cũng như gói các xử lý chung để nâng cao khả năng bảo trì.

  • Định nghĩa kiểu trong TypeScript và bọc các thao tác IndexedDB với Promise/async/await sẽ nâng cao an toàn và khả năng đọc mã.
  • Thay đổi lược đồ nên sử dụng quản lý phiên bản qua onupgradeneeded, và xử lý nặng nên trì hoãn khi có thể.
  • Thiết kế giao dịch ngắn và tránh xử lý bất đồng bộ nặng trong cùng một giao dịch.
  • Bằng cách tạo các lớp bao bọc, bạn có thể giảm các xử lý chung dư thừa như xử lý lỗi, ghi log và định nghĩa kiểu.

Bạn có thể làm theo bài viết trên bằng cách sử dụng Visual Studio Code trên kênh YouTube của chúng tôi. Vui lòng ghé thăm kênh YouTube.

YouTube Video