TypeScript und IndexedDB

TypeScript und IndexedDB

Dieser Artikel erklärt TypeScript und IndexedDB.

Wir erklären TypeScript und IndexedDB anhand von praktischen Beispielen.

YouTube Video

TypeScript und IndexedDB

IndexedDB ist ein Low-Level-NoSQL-Speicher, mit dem Sie strukturierte Daten im Browser dauerhaft speichern können. Mit TypeScript können Sie Schemata typsicher darstellen, was Fehler reduziert und die Wartbarkeit verbessert.

Grundbegriffe und Arbeitsablauf

IndexedDB ist eine kleine Datenbank im Browser. Sie verwaltet Daten mithilfe von Mechanismen wie Datenbanken mit Namen und Versionen, Objektspeichern, Transaktionen, Indizes und Cursoren. Datenbanken haben Versionen, und wenn die Version erhöht wird, wird onupgradeneeded aufgerufen, um das Schema zu aktualisieren, z.B. durch Erstellen oder Ändern von Tabellen.

Öffnen von IndexedDB (Grundmuster)

Zunächst zeigen wir ein Beispiel, wie man eine Datenbank mit IndexedDB öffnet und bei Bedarf im onupgradeneeded-Event einen Objektspeicher erstellt.

 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));
  • Dieser Code öffnet die Datenbank und protokolliert, ob der Vorgang erfolgreich war oder fehlgeschlagen ist.
  • Falls erforderlich, wird der todos-Speicher im onupgradeneeded-Event erstellt.

Definition von Typen in TypeScript (Modelle)

Als Nächstes definieren wir die Datentypen mit TypeScript. Dadurch wird Typensicherheit bei den nachfolgenden CRUD-Operationen gewährleistet.

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}
  • Hier definieren wir den Typ Todo.

Einfache Beispiele für Implementierungen von CRUD-Funktionen

Als nächstes zeigen wir Grundoperationen wie Hinzufügen, Abrufen, Aktualisieren und Löschen im Objektspeicher. Jede Funktion nimmt eine IDBDatabase-Instanz entgegen und gibt ein Promise zurück.

 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}
  • Diese Funktion fügt ein neues Todo zum todos-Speicher in IndexedDB hinzu. Sie gibt ein Promise zur asynchronen Verarbeitung zurück, das aufgelöst wird, wenn die Verarbeitung abgeschlossen ist.
 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}
  • Diese Funktion ruft das Todo mit der angegebenen ID ab und gibt das Objekt zurück, falls es gefunden wird. Wenn keine passenden Daten gefunden werden, gibt sie undefined zurück.
 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}
  • Diese Funktion aktualisiert vorhandene Todo-Daten. Bei Erfolg wird die ID des aktualisierten Todo protokolliert.
 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}
  • Diese Funktion löscht das Todo mit der angegebenen ID. Bei erfolgreicher Verarbeitung wird die gelöschte ID protokolliert.

  • Diese Funktionen lösen ein Promise auf oder lehnen es ab, je nach Abschluss oder Fehler der Transaktion. Die Ausgabe über console.log erleichtert das Nachverfolgen, was während der Ausführung geschieht.

Indizes und zusammengesetzte Abfragen

Mit Indizes in IndexedDB können Sie effizient auf bestimmten Feldern suchen. Hier erstellen wir einen Index für createdAt und zeigen ein Beispiel für eine Bereichsabfrage.

 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}
  • Diese Funktion öffnet die Datenbank und erstellt oder prüft den by-createdAt-Index auf dem Feld createdAt. Dies ermöglicht effizientes Suchen und Sortieren nach dem Erstellungsdatum.
 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}
  • Diese Funktion ruft nur die Todos ab, die nach dem angegebenen Zeitstempel erstellt wurden. Die Verwendung des Index ermöglicht ein effizientes Durchsuchen der Daten in der Reihenfolge des Erstellungsdatums.

  • In diesem Beispiel wird während des Datenbank-Upgrades ein by-createdAt-Index erstellt, und Todo-Einträge, die nach der angegebenen Zeit erstellt wurden, werden mit einem Cursor aufgelistet.

Promise-basierter, leichtgewichtiger Wrapper

Die Low-Level-IndexedDB-API ist kompliziert zu schreiben, und wiederholte ähnliche Operationen können zu Redundanz und Fehlern führen. Daher verbessern wir die Einfachheit und Wartbarkeit des Codes, indem wir eine generische TypeScript-Wrapper-Klasse bereitstellen, die die Operationen abstrahiert. Nachfolgend finden Sie eine Implementierung mit Fokus auf grundlegende Funktionalität.

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  }
  • Diese Klasse kapselt IndexedDB-Operationen und bietet typsichere CRUD-Methoden. Sie geht davon aus, dass ein Objektspeicher mit dem Schlüssel id existiert.
 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  }
  • Sie öffnet die Datenbank und erstellt bei Bedarf einen neuen Objektspeicher. Die Initialisierung des Speichers erfolgt über das Upgrade-Ereignis.
 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  }
  • Fügt Daten dem IndexedDB-Speicher hinzu. Nach dem Hinzufügen wird die ID in die Konsole ausgegeben.
 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  }
  • Ruft Daten zur angegebenen ID ab. undefined wird zurückgegeben, wenn die Daten nicht existieren.
 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  }
  • Aktualisiert bestehende Daten oder fügt neue Daten hinzu. Nach der Verarbeitung wird die aktualisierte ID protokolliert.
 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  }
  • Löscht die Daten mit der angegebenen ID. Bei Erfolg wird die gelöschte ID protokolliert.
 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}
  • Ruft alle Daten im Speicher ab. Der Rückgabewert ist ein Array vom Typ 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})();
  • Dieser Code ist ein tatsächliches Anwendungsbeispiel für die IDBWrapper-Klasse. Dies zeigt den Ablauf für das Hinzufügen, Abrufen, Aktualisieren, Auflisten und Löschen von Todo-Daten.

  • Dieser Wrapper ermöglicht eine einfache Ausführung grundlegender CRUD-Operationen. In einer realen Umgebung müssen Sie außerdem Fehlerbehandlung und Schemaverwaltung (Indizes) berücksichtigen.

Schemamigration (Versions-Upgrade)

Um das Datenbankschema zu ändern, erhöhen Sie das zweite Argument (Version) von indexedDB.open und aktualisieren Sie es in onupgradeneeded. Sie müssen es so gestalten, dass bestehende Transaktionen abgeschlossen sind und destruktive Änderungen vermieden werden.

 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}
  • Schwere Verarbeitung in onupgradeneeded kann die Benutzeroberfläche blockieren; halten Sie diese daher minimal und ziehen Sie wenn möglich verzögerte Migration (Staging-Prozesse beim App-Start) in Betracht.

Hinweise zu Transaktionen (Lebenszyklus und Fehler)

Transaktionen werden automatisch abgeschlossen, bevor das Skript, das sie erstellt hat, beendet ist. Bei Verwendung von await innerhalb einer Transaktion kann diese unerwartet abgeschlossen werden; Vorsicht ist geboten, wenn mehrere asynchrone Operationen in derselben Transaktion ablaufen.

 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}
  • Beachten Sie die Lebensdauer von Transaktionen; verwenden Sie bei Bedarf separate Transaktionen oder führen Sie die Operationen synchron innerhalb einer Transaktion aus.

Cursor-Anwendungen und Paginierung

Mit einem Cursor können Sie große Datenmengen sequentiell verarbeiten oder eine einfache Seitennummerierung ohne Offsets implementieren.

 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}
  • Durch das sequentielle Abrufen per Cursor kann der Speicherverbrauch reduziert werden. Bei der Implementierung von Paging ist es üblich, sich den zuletzt gelesenen Schlüssel zu merken.

Fehlerbehandlung und 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 ist möglicherweise aufgrund von Implementierungsunterschieden zwischen Browsern oder den Datenschutzeinstellungen der Benutzer, wie beispielsweise dem privaten Modus, nicht verfügbar. Daher sollte überprüft werden, ob indexedDB vorhanden ist, und falls nicht, sollte ein Fallback wie localStorage bereitgestellt werden.

Leistung und Best Practices

IndexedDB ist schnell und leistungsfähig, aber die Leistung kann je nach Design und Datenverarbeitung stark variieren. Je nach Anwendungsfall kann die Optimierung auf folgende Weise erfolgen:.

  • Gestalten Sie den Objektspeicher entsprechend der tatsächlichen Nutzung. Wenn z.B. viele Lesezugriffe erfolgen, nutzen Sie Indizes; bei vielen Schreibzugriffen halten Sie das Schlüssel-Design möglichst einfach.
  • Große Binärdaten wie Bilder und Audiodateien sollten als Blobs gespeichert oder bei Bedarf mit der File API oder über Service Worker verwaltet werden. Falls erforderlich kann auch eine Komprimierung in Betracht gezogen werden.
  • Halten Sie Transaktionen so kurz wie möglich und führen Sie aufwendige Verarbeitungen außerhalb der Transaktion durch, um die Sperrzeit zu minimieren.
  • Indizes können Suchvorgänge beschleunigen, aber das Einfügen und Aktualisieren verlangsamen, daher sollten nur unbedingt notwendige Indizes angelegt werden.
  • Wenn viele kleine Datenelemente vorhanden sind, kann das gleichzeitige Abrufen mit getAll() zu einem Speichermangel führen. Sie können den Speicherverbrauch verringern, indem Sie die Verarbeitung mit Cursoren aufteilen.

Sicherheit und Datenschutz

IndexedDB-Daten sind gemäß Same-Origin-Policy pro Domain und Protokoll isoliert. Gehen Sie davon aus, dass Daten verloren gehen können, wenn Nutzer Browserdaten löschen oder den privaten Modus verwenden.

Zusammenfassung und empfohlene Entwurfsmuster

Um IndexedDB effektiv mit TypeScript zu nutzen, ist es wichtig, Typen und asynchrone Prozesse vorzubereiten, auf Versionsverwaltung und Transaktionsgestaltung zu achten und häufig verwendete Abläufe zu kapseln, um die Wartbarkeit zu erhöhen.

  • Das Definieren von Typen in TypeScript und das Kapseln von IndexedDB-Operationen mit Promise/async/await verbessert die Sicherheit und Lesbarkeit des Codes.
  • Schemaänderungen sollten mit Versionsverwaltung und onupgradeneeded durchgeführt werden, schwere Verarbeitung sollte wenn möglich verzögert werden.
  • Trennen Sie Transaktionen kurz und vermeiden Sie umfangreiche asynchrone Verarbeitung innerhalb derselben Transaktion.
  • Durch das Erstellen von Wrapper-Klassen können Sie wiederholende Abläufe wie Fehlerbehandlung, Logging und Typdefinitionen reduzieren.

Sie können den obigen Artikel mit Visual Studio Code auf unserem YouTube-Kanal verfolgen. Bitte schauen Sie sich auch den YouTube-Kanal an.

YouTube Video