TypeScript i IndexedDB
Ten artykuł wyjaśnia temat TypeScript i IndexedDB.
Wyjaśnimy TypeScript i IndexedDB za pomocą praktycznych przykładów.
YouTube Video
TypeScript i IndexedDB
IndexedDB to niskopoziomowe rozwiązanie NoSQL umożliwiające trwałe przechowywanie danych strukturalnych w przeglądarce. W TypeScript możesz reprezentować schematy w sposób bezpieczny typowo, zmniejszając liczbę błędów i poprawiając możliwość utrzymania kodu.
Podstawowa terminologia i przepływ pracy
IndexedDB to mała baza danych wewnątrz przeglądarki. Zarządza danymi przy użyciu mechanizmów takich jak bazy danych z nazwami i wersjami, magazyny obiektów, transakcje, indeksy i kursory. Bazy danych mają wersje, a podczas aktualizacji wersji wywoływana jest funkcja onupgradeneeded w celu zaktualizowania schematu, np. poprzez tworzenie lub modyfikowanie tabel.
Otwieranie IndexedDB (podstawowy schemat)
Najpierw przedstawiamy przykład otwarcia bazy danych w IndexedDB oraz utworzenia magazynu obiektów w onupgradeneeded, jeśli to konieczne.
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));- Ten kod otwiera bazę danych i zapisuje, czy operacja zakończyła się sukcesem, czy porażką.
- Jeśli to konieczne, magazyn
todosjest tworzony wonupgradeneeded.
Definiowanie typów w TypeScript (Modele)
Następnie definiujemy typy danych za pomocą TypeScript. Zapewnia to bezpieczeństwo typów w kolejnych operacjach CRUD.
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}- Tutaj definiujemy typ
Todo.
Przykłady prostych implementacji funkcji CRUD
Następnie pokazujemy podstawowe operacje CRUD, takie jak dodawanie, pobieranie, aktualizowanie i usuwanie z magazynu obiektów. Każda funkcja przyjmuje IDBDatabase i zwraca obietnicę (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}- Ta funkcja dodaje nowy element
Tododo magazynutodosw IndexedDB. Zwraca obietnicę (Promise) do obsługi asynchronicznej, która zostaje spełniona po zakończeniu przetwarzania.
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}- Ta funkcja pobiera element
Todoo określonym ID i zwraca obiekt, jeśli zostanie znaleziony. Jeśli nie znaleziono pasujących danych, zwracaundefined.
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}- Ta funkcja aktualizuje istniejące dane
Todo. W przypadku powodzenia zapisywany jest ID zaktualizowanegoTodo.
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}-
Ta funkcja usuwa element
Todoo określonym ID. Po pomyślnym przetworzeniu identyfikator usuniętego elementu jest logowany. -
Te funkcje rozwiązują lub odrzucają Promise w zależności od ukończenia transakcji lub wystąpienia błędów. Wyprowadzanie informacji za pomocą
console.logułatwia śledzenie przebiegu wykonywania kodu.
Indeksy i zapytania złożone
Używając indeksów w IndexedDB, można efektywnie wyszukiwać po określonych polach. Tutaj tworzymy indeks na polu createdAt i pokazujemy przykład zapytania zakresowego.
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}- Ta funkcja otwiera bazę danych oraz tworzy lub weryfikuje indeks
by-createdAtna polucreatedAt. Pozwala to na efektywne wyszukiwanie i sortowanie według daty utworzenia.
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}-
Ta funkcja pobiera tylko elementy
Todoutworzone po określonym znaczniku czasu. Użycie indeksu pozwala na efektywne przeszukiwanie danych według kolejności daty utworzenia. -
W tym przykładzie podczas aktualizacji bazy danych tworzony jest indeks
by-createdAt, a elementyTodoutworzone po określonym czasie są wyliczane za pomocą kursora.
Lekki wrapper oparty na Promise
Niskopoziomowe API IndexedDB jest skomplikowane do pisania, a powtarzające się podobne operacje mogą prowadzić do nadmiarowości i błędów. Dlatego, przygotowując ogólną klasę typu wrapper w TypeScript, która abstrahuje te operacje, poprawiamy prostotę i łatwość utrzymania kodu. Poniżej znajduje się implementacja skupiona na podstawowej funkcjonalności.
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 }- Ta klasa opakowuje operacje na IndexedDB oraz udostępnia metody CRUD bezpieczne typowo. Zakłada magazyn obiektów, w którym kluczem jest
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 }- Otwiera bazę danych i tworzy nowy magazyn obiektów jeśli to konieczne. Inicjalizacja magazynu wykonywana jest podczas zdarzenia aktualizacji (upgrade).
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 }- Dodaje dane do magazynu IndexedDB. Po dodaniu identyfikator jest wyświetlany w konsoli.
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 }- Pobiera dane odpowiadające podanemu identyfikatorowi.
undefinedjest zwracane, jeśli dane nie istnieją.
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 }- Aktualizuje istniejące dane lub dodaje nowe. Po przetworzeniu logowany jest zaktualizowany identyfikator.
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 }- Usuwa dane o określonym identyfikatorze. W przypadku sukcesu logowany jest usunięty identyfikator.
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}- Pobiera wszystkie dane z magazynu. Wartością zwrotną jest tablica typu
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})();-
Ten kod to przykładowe użycie klasy
IDBWrapper. To pokazuje proces dodawania, pobierania, aktualizowania, wyświetlania i usuwania danychTodo. -
Ten wrapper umożliwia prostą obsługę podstawowych operacji CRUD. W rzeczywistym środowisku konieczne jest również obsługiwanie błędów oraz zarządzanie schematem (indeksy).
Migracja schematu (aktualizacja wersji)
Aby zmienić schemat bazy danych, zwiększ drugi argument (wersję) funkcji indexedDB.open i zaktualizuj go w metodzie onupgradeneeded. Musisz zaprojektować to w taki sposób, aby bieżące transakcje zostały zakończone, a destrukcyjne zmiany zostały uniknięte.
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}- Ciężkie operacje w
onupgradeneededmogą blokować interfejs użytkownika, więc należy je ograniczyć do minimum; jeśli to możliwe, rozważ migrację wykonywaną później (podczas uruchamiania aplikacji).
Uwaga na transakcje (cykl życia i błędy)
Transakcje są automatycznie zatwierdzane przed zakończeniem wykonania skryptu, który je utworzył. Używanie await wewnątrz transakcji może spowodować jej nieoczekiwane zatwierdzenie; należy zachować ostrożność przy wykonywaniu wielu operacji asynchronicznych w tej samej transakcji.
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}- Zwróć uwagę na czas życia transakcji; jeśli to konieczne, użyj osobnych transakcji lub zaplanuj operacje synchronicznie w ramach jednej transakcji.
Zastosowania kursorów i stronicowanie
Używając kursora, możesz przetwarzać duże ilości danych sekwencyjnie lub zaimplementować prostą paginację bez użycia offsetów.
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}- Pobierając elementy pojedynczo za pomocą kursorów, można zmniejszyć użycie pamięci. Podczas implementacji stronicowania często zapamiętuje się ostatni odczytany klucz.
Obsługa błędów i mechanizmy zapasowe
1// Feature detection
2if (!('indexedDB' in window)) {
3 console.warn('IndexedDB is not supported. Falling back to localStorage.');
4 // implement fallback logic...
5}- IndexedDB może być niedostępna z powodu różnic w implementacji pomiędzy przeglądarkami lub ustawieniami prywatności użytkownika, takimi jak tryb przeglądania prywatnego. Dlatego należy sprawdzić, czy istnieje
indexedDB, a jeśli nie, zastosować alternatywę, taką jaklocalStorage.
Wydajność i najlepsze praktyki
IndexedDB jest szybki i wydajny, ale jego wydajność może się znacznie różnić w zależności od projektu oraz sposobu przetwarzania danych. W zależności od przypadku użycia, optymalizację można przeprowadzić w następujący sposób:.
- Zaprojktuj magazyn obiektów zgodnie z rzeczywistym użyciem. Na przykład, jeśli występuje wiele odczytów, należy zapewnić indeksy; jeśli jest dużo zapisów, należy utrzymać prosty projekt kluczy.
- Duże dane binarne, takie jak obrazy i dźwięki, powinny być przechowywane jako Bloby lub zarządzane za pomocą File API albo service workerów, jeśli to konieczne. W razie potrzeby można również rozważyć kompresję.
- Utrzymuj transakcje tak krótkie, jak to możliwe i wykonuj ciężkie operacje poza transakcją, aby zminimalizować czas blokady.
- Indeksy mogą przyspieszyć wyszukiwanie, ale spowalniają wstawianie i aktualizacje, więc twórz tylko te, które są naprawdę potrzebne.
- W przypadku pracy z wieloma małymi fragmentami danych, pobieranie ich wszystkich naraz za pomocą
getAll()może wyczerpać pamięć. Możesz zmniejszyć użycie pamięci, rozdzielając przetwarzanie za pomocą kursorów.
Bezpieczeństwo i prywatność
Dane IndexedDB są izolowane dla każdej domeny i protokołu zgodnie z zasadą same-origin. Projektuj zakładając, że dane mogą zostać utracone, jeśli użytkownicy usuną dane przeglądarki lub skorzystają z trybu prywatnego.
Podsumowanie i zalecane wzorce projektowe
Aby skutecznie korzystać z IndexedDB z TypeScriptem, ważne jest przygotowanie typów i procesów asynchronicznych, świadomość zarządzania wersjami i projektowania transakcji oraz zamknięcie wspólnych operacji w funkcje ułatwiające utrzymanie.
- Definiowanie typów w TypeScript oraz opakowywanie operacji IndexedDB za pomocą Promise/async/await zwiększa bezpieczeństwo i czytelność kodu.
- Zmiany schematu powinny odbywać się poprzez zarządzanie wersjami w
onupgradeneeded, a ciężkie operacje należy opóźniać, jeśli to możliwe. - Projektuj transakcje tak, aby były krótkie i unikaj ciężkiego asynchronicznego przetwarzania w ramach jednej transakcji.
- Tworząc klasy opakowujące, możesz ograniczyć powtarzające się wspólne procesy, takie jak obsługa błędów, logowanie i definiowanie typów.
Możesz śledzić ten artykuł, korzystając z Visual Studio Code na naszym kanale YouTube. Proszę również sprawdzić nasz kanał YouTube.