TypeScript i IndexedDB

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 todos jest tworzony w onupgradeneeded.

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 Todo do magazynu todos w 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 Todo o określonym ID i zwraca obiekt, jeśli zostanie znaleziony. Jeśli nie znaleziono pasujących danych, zwraca 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}
  • Ta funkcja aktualizuje istniejące dane Todo. W przypadku powodzenia zapisywany jest ID zaktualizowanego 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}
  • Ta funkcja usuwa element Todo o 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.log uł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-createdAt na polu createdAt. 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 Todo utworzone 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 elementy Todo utworzone 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. undefined jest 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 danych Todo.

  • 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 onupgradeneeded mogą 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ą jak localStorage.

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.

YouTube Video