TypeScript e IndexedDB

TypeScript e IndexedDB

Questo articolo spiega TypeScript e IndexedDB.

Spiegheremo TypeScript e IndexedDB con esempi pratici.

YouTube Video

TypeScript e IndexedDB

IndexedDB è una memoria NoSQL di basso livello che consente di memorizzare dati strutturati nel browser. Con TypeScript, è possibile rappresentare gli schemi in modo tipizzato, riducendo gli errori e migliorando la manutenibilità.

Terminologia di base e flusso di lavoro

IndexedDB è un piccolo database all'interno del browser. Gestisce i dati utilizzando meccanismi come database con nomi e versioni, archivi di oggetti, transazioni, indici e cursori. I database hanno delle versioni e, quando la versione viene aggiornata, viene chiamato onupgradeneeded per aggiornare lo schema, ad esempio creando o modificando tabelle.

Apertura di IndexedDB (schema di base)

Per prima cosa, mostriamo un esempio di apertura di un database con IndexedDB e di creazione di un object store in onupgradeneeded se necessario.

 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));
  • Questo codice apre il database e registra se l'operazione è riuscita o fallita.
  • Se necessario, lo store todos viene creato in onupgradeneeded.

Definizione dei tipi in TypeScript (Modelli)

Successivamente definiamo i tipi di dati utilizzando TypeScript. Questo garantisce la sicurezza del tipo nelle successive operazioni 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}
  • Qui definiamo il tipo Todo.

Esempi di implementazione di semplici funzioni CRUD

Successivamente, mostriamo le operazioni CRUD di base come aggiungere, recuperare, aggiornare ed eliminare dall'object store. Ogni funzione prende un IDBDatabase e restituisce una 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}
  • Questa funzione aggiunge un nuovo Todo all'archivio todos in IndexedDB. Restituisce una Promise per la gestione asincrona, che si risolve quando l'elaborazione è completata.
 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}
  • Questa funzione recupera il Todo con l'ID specificato e restituisce l'oggetto se trovato. Se non vengono trovati dati corrispondenti, restituisce 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}
  • Questa funzione aggiorna i dati esistenti di Todo. In caso di successo, viene registrato l'ID del Todo aggiornato.
 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}
  • Questa funzione elimina il Todo con l'ID specificato. Quando l'elaborazione ha successo, viene registrato l'ID eliminato.

  • Queste funzioni risolvono o rifiutano una Promise a seconda del completamento della transazione o degli errori. Includere l'output tramite console.log facilita il tracciamento di ciò che accade durante l'esecuzione.

Indici e query composte

Utilizzando gli indici in IndexedDB, è possibile cercare in modo efficiente su campi specifici. Qui creiamo un indice per createdAt e diamo un esempio di query di intervallo.

 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}
  • Questa funzione apre il database e crea o verifica l'indice by-createdAt sul campo createdAt. Questo consente una ricerca e un ordinamento efficiente per data di creazione.
 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}
  • Questa funzione recupera solo i Todo creati dopo il timestamp specificato. L'uso dell'indice consente una scansione efficiente dei dati in ordine di data di creazione.

  • In questo esempio, viene creato un indice by-createdAt durante l'aggiornamento del database e gli elementi Todo creati dopo il tempo specificato vengono enumerati con un cursore.

Wrapper leggero basato su Promise

L’API IndexedDB di basso livello è complessa da scrivere e il ripetere operazioni simili può portare a ridondanza ed errori. Pertanto, preparando una classe wrapper TypeScript generica che astrae le operazioni, miglioriamo la semplicità e la manutenibilità del codice. Di seguito è riportata un'implementazione focalizzata sulle funzionalità di base.

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  }
  • Questa classe avvolge le operazioni IndexedDB e fornisce metodi CRUD tipizzati. Si assume uno store oggetti dove la chiave è 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  }
  • Apre il database e crea un nuovo store di oggetti se necessario. L'inizializzazione dello store viene eseguita utilizzando l'evento di 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  }
  • Aggiunge dati allo store IndexedDB. Dopo aver aggiunto, l'ID viene registrato sulla 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  }
  • Recupera i dati corrispondenti all'ID specificato. Viene restituito undefined se i dati non esistono.
 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  }
  • Aggiorna i dati esistenti o aggiunge nuovi dati. Dopo l'elaborazione, l'ID aggiornato viene registrato.
 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  }
  • Elimina i dati con l'ID specificato. In caso di successo, l'ID eliminato viene registrato.
 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}
  • Recupera tutti i dati nello store. Il valore restituito è un array di tipo 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})();
  • Questo codice è un esempio reale di utilizzo della classe IDBWrapper. Questo mostra il flusso per aggiungere, recuperare, aggiornare, elencare ed eliminare dati di Todo.

  • Questo wrapper consente una gestione semplice delle operazioni CRUD di base. In un ambiente reale, è anche necessario gestire la gestione degli errori e la gestione dello schema (indici).

Migrazione dello schema (aggiornamento versione)

Per modificare lo schema del database, aumenta il secondo argomento (versione) di indexedDB.open e aggiornalo in onupgradeneeded. Devi progettare in modo che le transazioni esistenti siano terminate e che si evitino cambiamenti distruttivi.

 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}
  • Elaborazioni pesanti all'interno di onupgradeneeded possono bloccare l'interfaccia utente, quindi mantenerle al minimo e considerare la migrazione posticipata (elaborazione in fase di avvio dell'app) se possibile.

Attenzioni sulle transazioni (ciclo di vita ed errori)

Le transazioni vengono automaticamente confermate prima che termini l'esecuzione dello script che le ha create. Quando si usa await all'interno di una transazione, può essere confermata inaspettatamente; attenzione quando si eseguono più operazioni asincrone all'interno della stessa transazione.

 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}
  • Tieni presente la durata delle transazioni; usa transazioni separate se necessario, o pianifica le operazioni in modo sincrono all'interno di una transazione.

Applicazioni di cursori e paginazione

Utilizzando un cursore, puoi elaborare grandi quantità di dati in modo sequenziale o implementare una semplice paginazione senza usare 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}
  • Recuperando gli elementi uno alla volta con i cursori, è possibile ridurre l'uso della memoria. Quando si implementa la paginazione, è comune ricordare l'ultima chiave letta.

Gestione degli errori e 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 potrebbe non essere disponibile a causa delle differenze di implementazione tra i browser o delle impostazioni di privacy degli utenti, come la navigazione privata. Pertanto, verifica se indexedDB esiste e, in caso contrario, fornisci un'alternativa come localStorage.

Prestazioni e buone pratiche

IndexedDB è veloce e potente, ma le prestazioni possono variare notevolmente a seconda del suo design e di come vengono gestiti i dati. A seconda del caso d'uso, l'ottimizzazione può essere fatta nei seguenti modi:.

  • Progetta l'archivio degli oggetti in base all'utilizzo effettivo. Ad esempio, se ci sono molte letture, fornisci degli indici; se ci sono molte scritture, mantieni semplice il design delle chiavi.
  • I grandi dati binari come immagini e audio dovrebbero essere archiviati come Blob, oppure gestiti utilizzando le API File o i service worker se necessario. La compressione può anche essere presa in considerazione se necessario.
  • Mantieni le transazioni il più brevi possibile ed esegui processi pesanti al di fuori della transazione per minimizzare il tempo di blocco.
  • Gli indici possono velocizzare le ricerche, ma rallentare le inserzioni e gli aggiornamenti, quindi crea solo quelli realmente necessari.
  • Quando si gestiscono molti piccoli dati, recuperarli tutti in una volta con getAll() può esaurire la memoria. Puoi ridurre l'utilizzo della memoria suddividendo l'elaborazione con i cursori.

Sicurezza e privacy

I dati di IndexedDB sono isolati per dominio e protocollo secondo la politica della stessa origine (same-origin policy). Progetta con l'assunzione che i dati possano essere persi se gli utenti eliminano i dati del browser o usano la modalità privata.

Riepilogo e pattern di progettazione consigliati

Per utilizzare efficacemente IndexedDB con TypeScript, è importante preparare i tipi e i processi asincroni, tenere conto della gestione delle versioni e della progettazione delle transazioni e incapsulare i processi comuni per migliorare la manutenibilità.

  • Definire i tipi in TypeScript e racchiudere le operazioni IndexedDB con Promise/async/await migliora la sicurezza e la leggibilità del codice.
  • Le modifiche dello schema dovrebbero usare la gestione delle versioni con onupgradeneeded, e le elaborazioni pesanti andrebbero rimandate quando possibile.
  • Progetta le transazioni in modo che siano brevi ed evita elaborazioni asincrone pesanti all'interno della stessa transazione.
  • Creando classi wrapper, puoi ridurre i processi comuni ridondanti come la gestione degli errori, il logging e la definizione dei tipi.

Puoi seguire l'articolo sopra utilizzando Visual Studio Code sul nostro canale YouTube. Controlla anche il nostro canale YouTube.

YouTube Video