TypeScript en IndexedDB

TypeScript en IndexedDB

In dit artikel wordt TypeScript en IndexedDB uitgelegd.

We leggen TypeScript en IndexedDB uit met praktische voorbeelden.

YouTube Video

TypeScript en IndexedDB

IndexedDB is een low-level NoSQL opslag waarmee je gestructureerde data kunt opslaan in de browser. Met TypeScript kun je schema's type-veilig weergeven, waardoor fouten worden verminderd en het onderhoud wordt verbeterd.

Basis terminologie en workflow

IndexedDB is een kleine database in de browser. Het beheert gegevens met mechanismen zoals databases met namen en versies, object stores, transacties, indexen en cursors. Databases hebben versies en wanneer de versie wordt bijgewerkt, wordt onupgradeneeded aangeroepen om het schema bij te werken, zoals het aanmaken of aanpassen van tabellen.

IndexedDB openen (basispatroon)

Eerst tonen we een voorbeeld van het openen van een database met IndexedDB en het aanmaken van een object store in onupgradeneeded indien nodig.

 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));
  • Deze code opent de database en registreert of het gelukt is of is mislukt.
  • Indien nodig wordt de todos store aangemaakt in onupgradeneeded.

Types definiëren in TypeScript (Modellen)

Vervolgens definiëren we datatypes met TypeScript. Dit zorgt voor type-veiligheid bij latere CRUD-bewerkingen.

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 definiëren we het Todo type.

Eenvoudige voorbeelden van CRUD-functie-implementaties

Daarna tonen we basis CRUD-bewerkingen zoals toevoegen, ophalen, bijwerken en verwijderen uit de object store. Elke functie ontvangt een IDBDatabase en retourneert een 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}
  • Deze functie voegt een nieuwe Todo toe aan de todos store in IndexedDB. Het retourneert een Promise voor asynchrone afhandeling, die wordt opgelost als het proces is voltooid.
 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}
  • Deze functie haalt de Todo met de opgegeven ID op en retourneert het object indien gevonden. Als er geen overeenkomende data gevonden wordt, retourneert het 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}
  • Deze functie werkt bestaande Todo-gegevens bij. Bij succes wordt de ID van de bijgewerkte Todo geregistreerd.
 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}
  • Deze functie verwijdert de Todo met de opgegeven ID. Als het proces succesvol is, wordt het verwijderde ID gelogd.

  • Deze functies lossen een Promise op of wijzen hem af, afhankelijk van het voltooien van de transactie of fouten. Uitvoer via console.log maakt het eenvoudig om het verloop van de uitvoering te volgen.

Indexen en samengestelde zoekopdrachten

Door het gebruik van indexen in IndexedDB kun je efficiënt zoeken op specifieke velden. Hier maken we een index voor createdAt en geven we een voorbeeld van een range query.

 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}
  • Deze functie opent de database en maakt of controleert de by-createdAt index op het veld createdAt. Dit maakt efficiënt zoeken en sorteren op creatiedatum mogelijk.
 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}
  • Deze functie haalt alleen de Todos op die na de opgegeven timestamp zijn aangemaakt. Het gebruik van de index maakt efficiënte data-scanning op volgorde van creatiedatum mogelijk.

  • In dit voorbeeld wordt een by-createdAt index aangemaakt tijdens de database-upgrade, en worden Todo-items die na de opgegeven tijd zijn aangemaakt opgehaald met een cursor.

Lichtgewicht wrapper op basis van Promises

De low-level IndexedDB API is complex om mee te werken, en herhaalde soortgelijke bewerkingen kunnen leiden tot overbodigheid en bugs. Daarom verbeteren we de eenvoud en het onderhoud van code door een generieke TypeScript-wrapperklasse te maken die de bewerkingen abstraheert. Hieronder volgt een implementatie gericht op basisfunctionaliteit.

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  }
  • Deze klasse wikkelt IndexedDB-bewerkingen in en biedt type-veilige CRUD-methoden. Er wordt uitgegaan van een object store waarbij de sleutel id is.
 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  }
  • Het opent de database en maakt indien nodig een nieuwe object store aan. Initialisatie van de store wordt uitgevoerd via het 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  }
  • Voegt data toe aan de IndexedDB-store. Na het toevoegen wordt het ID gelogd naar de 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  }
  • Haalt data op die overeenkomt met het opgegeven ID. undefined wordt geretourneerd als de data niet bestaat.
 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  }
  • Werkt bestaande data bij of voegt nieuwe data toe. Na verwerking wordt het bijgewerkte ID gelogd.
 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  }
  • Verwijdert de data met het opgegeven ID. Bij succes wordt het verwijderde ID gelogd.
 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}
  • Haalt alle data in de store op. De teruggegeven waarde is een array van type 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})();
  • Deze code is een daadwerkelijk gebruiksvoorbeeld van de IDBWrapper klasse. Dit toont het proces voor het toevoegen, ophalen, bijwerken, weergeven en verwijderen van Todo-gegevens.

  • Deze wrapper maakt eenvoudige afhandeling van basis CRUD-operaties mogelijk. In een echte omgeving moet je ook foutafhandeling en schema-beheer (indexen) verwerken.

Schemamigratie (versie-upgrade)

Om het databaseschema te wijzigen, verhoog je het tweede argument (versie) van indexedDB.open en werk je het bij in onupgradeneeded. Je moet het zo ontwerpen dat bestaande transacties zijn voltooid en destructieve wijzigingen worden vermeden.

 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}
  • Zware verwerking in onupgradeneeded kan de UI blokkeren, houd dit dus zo minimaal mogelijk en overweeg vertraagde migratie (verwerken bij de start van de app) indien mogelijk.

Let op met transacties (levenscyclus en fouten)

Transacties worden automatisch bevestigd voordat de uitvoering van het script dat ze heeft aangemaakt eindigt. Bij gebruik van await binnen een transactie kan deze onverwacht worden bevestigd; wees voorzichtig als je meerdere asynchrone bewerkingen uitvoert binnen dezelfde transactie.

 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}
  • Wees bewust van de levensduur van een transactie; gebruik indien nodig aparte transacties of plan bewerkingen synchroon binnen een transactie.

Cursor-toepassingen en paginering

Door een cursor te gebruiken, kun je grootschalige gegevens sequentieel verwerken of eenvoudige paginering implementeren zonder offsets te gebruiken.

 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}
  • Door items één voor één op te halen met cursors, kan het geheugengebruik worden verminderd. Bij het implementeren van paginering is het gebruikelijk om de laatst gelezen sleutel te onthouden.

Foutafhandeling en 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 is mogelijk niet beschikbaar vanwege implementatieverschillen tussen browsers of door privacy-instellingen van gebruikers, zoals privé browsen. Controleer daarom of indexedDB bestaat en voorzie, als dat niet het geval is, in een fallback zoals localStorage.

Prestaties en best practices

IndexedDB is snel en krachtig, maar de prestaties kunnen sterk variëren afhankelijk van het ontwerp en de manier waarop gegevens worden behandeld. Afhankelijk van het gebruik kan optimalisatie op de volgende manieren plaatsvinden:.

  • Ontwerp de object store op basis van het daadwerkelijke gebruik. Bijvoorbeeld: bij veel leesopdrachten, voorzie van indexen; bij veel schrijfopdrachten, houd het sleuteldesign eenvoudig.
  • Grote binaire data zoals afbeeldingen en audio moeten als Blobs worden opgeslagen, of beheerd worden via de File API of service workers indien nodig. Compressie kan indien nodig ook worden overwogen.
  • Houd transacties zo kort mogelijk en verricht zware bewerkingen buiten de transactie om de vergrendelingstijd te minimaliseren.
  • Indexen kunnen zoekopdrachten versnellen, maar het invoegen en bijwerken vertragen, maak daarom alleen indexen die echt nodig zijn.
  • Bij het werken met veel kleine stukjes data kan het ophalen ervan via getAll() teveel geheugen verbruiken. Je kunt het geheugengebruik verminderen door de verwerking op te splitsen met cursors.

Beveiliging en privacy

IndexedDB-data wordt per domein en protocol geïsoleerd volgens het same-origin beleid. Ontwerp met de aanname dat gegevens verloren kunnen gaan als gebruikers browsergegevens verwijderen of de privémodus gebruiken.

Samenvatting en aanbevolen ontwerppatronen

Om IndexedDB effectief te gebruiken met TypeScript, is het belangrijk om types en asynchrone processen voor te bereiden, rekening te houden met versiebeheer en transactiedesign, en veelvoorkomende processen te encapsuleren voor betere onderhoudbaarheid.

  • Types definiëren in TypeScript en IndexedDB-operaties omwikkelen met Promise/async/await verbetert de veiligheid en leesbaarheid van de code.
  • Schemawijzigingen dienen versiebeheer te gebruiken met onupgradeneeded, en zware verwerking moet indien mogelijk worden uitgesteld.
  • Ontwerp transacties zo kort mogelijk en vermijd zware asynchrone verwerking binnen dezelfde transactie.
  • Door wrapper-klassen te maken, kun je overbodige veelvoorkomende processen zoals foutafhandeling, logging en typdefinities verminderen.

Je kunt het bovenstaande artikel volgen met Visual Studio Code op ons YouTube-kanaal. Bekijk ook het YouTube-kanaal.

YouTube Video