TypeScript et IndexedDB
Cet article explique TypeScript et IndexedDB.
Nous expliquerons TypeScript et IndexedDB avec des exemples concrets.
YouTube Video
TypeScript et IndexedDB
IndexedDB est un stockage NoSQL de bas niveau qui vous permet de persister des données structurées dans le navigateur. Avec TypeScript, vous pouvez représenter les schémas de manière sécurisée par type, réduisant les erreurs et améliorant la maintenabilité.
Terminologie de base et flux de travail
IndexedDB est une petite base de données à l'intérieur du navigateur. Elle gère les données à l'aide de mécanismes tels que les bases de données avec des noms et des versions, les magasins d’objets, les transactions, les index et les curseurs. Les bases de données ont des versions, et lorsque la version est mise à jour, onupgradeneeded est appelé pour mettre à jour le schéma, par exemple en créant ou en modifiant des tables.
Ouverture d’IndexedDB (schéma de base)
Tout d’abord, nous montrons un exemple d’ouverture d’une base de données avec IndexedDB et de création d’un object store dans onupgradeneeded si nécessaire.
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));- Ce code ouvre la base de données et enregistre si l'opération a réussi ou échoué.
- Si nécessaire, le store
todosest créé dansonupgradeneeded.
Définition des types en TypeScript (Modèles)
Ensuite, nous définissons les types de données avec TypeScript. Cela garantit la sécurité des types dans les opérations CRUD suivantes.
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}- Ici, nous définissons le type
Todo.
Exemples de mise en œuvre des fonctions CRUD simples
Ensuite, nous montrons des opérations CRUD de base telles qu’ajouter, récupérer, mettre à jour et supprimer des éléments de l’object store. Chaque fonction prend une instance d’IDBDatabase et retourne une 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}- Cette fonction ajoute un nouveau
Todoau magasintodosdans IndexedDB. Elle retourne une Promise pour la gestion asynchrone, qui se résout lorsque le traitement est terminé.
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}- Cette fonction récupère le
Todoavec l’ID spécifié et renvoie l’objet s’il est trouvé. Si aucune donnée correspondante n’est trouvée, elle retourneundefined.
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}- Cette fonction met à jour les données existantes de
Todo. En cas de succès, l’ID duTodomis à jour est enregistré.
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}-
Cette fonction supprime le
Todoavec l’ID spécifié. Lorsque le traitement est réussi, l’identifiant supprimé est affiché dans la console. -
Ces fonctions valident ou rejettent une promesse en fonction de l’achèvement de la transaction ou des erreurs. Inclure des sorties via
console.logfacilite le suivi de ce qui se passe pendant l’exécution.
Index et requêtes composées
En utilisant des index dans IndexedDB, il est possible de rechercher efficacement sur des champs spécifiques. Ici, nous créons un index pour createdAt et donnons un exemple de requête de plage.
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}- Cette fonction ouvre la base de données et crée ou vérifie l’index
by-createdAtsur le champcreatedAt. Cela permet une recherche et un tri efficaces par date de création.
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}-
Cette fonction récupère uniquement les
Todoscréés après l’horodatage spécifié. L’utilisation de l’index permet de scanner efficacement les données par ordre de date de création. -
Dans cet exemple, un index
by-createdAtest créé lors de la mise à niveau de la base de données, et les élémentsTodocréés après l'heure spécifiée sont énumérés à l'aide d'un curseur.
Wrapper léger basé sur Promise
L'API IndexedDB de bas niveau est complexe à écrire, et la répétition d'opérations similaires peut entraîner de la redondance et des bugs. Par conséquent, en préparant une classe générique de wrapper TypeScript qui abstrait les opérations, nous améliorons la simplicité et la maintenabilité du code. Ci-dessous se trouve une implémentation axée sur les fonctionnalités de 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 }- Cette classe encapsule les opérations IndexedDB et fournit des méthodes CRUD sécurisées par type. Elle suppose un object store où la clé est
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 }- Elle ouvre la base de données et crée un nouvel object store si nécessaire. L’initialisation du store est effectuée lors de l’événement de mise à niveau.
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 }- Ajoute des données au store IndexedDB. Après l’ajout, l’identifiant est affiché dans la 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 }- Récupère les données correspondant à l’identifiant spécifié.
undefinedest retourné si les données n’existent pas.
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 }- Met à jour les données existantes ou en ajoute de nouvelles. Après traitement, l’identifiant mis à jour est affiché.
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 }- Supprime les données avec l’identifiant spécifié. En cas de succès, l’identifiant supprimé est affiché.
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}- Récupère toutes les données présentes dans le store. La valeur de retour est un tableau de 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})();-
Ce code est un exemple d’utilisation réelle de la classe
IDBWrapper. Ceci montre le flux pour ajouter, récupérer, mettre à jour, lister et supprimer les donnéesTodo. -
Ce wrapper permet une gestion simple des opérations CRUD basiques. Dans un environnement réel, il faut également gérer le traitement des erreurs et la gestion du schéma (index).
Migration de schéma (mise à niveau de version)
Pour modifier le schéma de la base de données, augmentez le deuxième argument (version) de indexedDB.open et mettez-le à jour dans onupgradeneeded. Vous devez le concevoir de sorte que les transactions existantes soient terminées et que les modifications destructives soient évitées.
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}- Un traitement lourd dans
onupgradeneededpeut bloquer l’interface utilisateur, donc limitez-le au strict minimum. Si possible, envisagez une migration différée (traitement différé lors du démarrage de l’application).
Précautions concernant les transactions (cycle de vie et erreurs)
Les transactions sont automatiquement validées avant la fin de l’exécution du script qui les a créées. Lorsque vous utilisez await à l’intérieur d’une transaction, celle-ci peut être validée de façon inattendue ; il faut donc faire attention lors de l’exécution de plusieurs opérations asynchrones dans une même transaction.
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}- Faites attention à la durée de vie des transactions ; utilisez des transactions séparées si nécessaire, ou planifiez les opérations de manière synchrone dans une transaction.
Applications des curseurs et pagination
En utilisant un curseur, vous pouvez traiter des données de grande taille de manière séquentielle ou implémenter une pagination simple sans utiliser d'offsets.
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}- En récupérant les éléments un par un avec un curseur, l’utilisation de la mémoire peut être réduite. Lorsqu’on implémente la pagination, il est courant de mémoriser la dernière clé lue.
Gestion des erreurs et alternatives
1// Feature detection
2if (!('indexedDB' in window)) {
3 console.warn('IndexedDB is not supported. Falling back to localStorage.');
4 // implement fallback logic...
5}- IndexedDB peut ne pas être disponible en raison de différences d'implémentation entre les navigateurs ou des paramètres de confidentialité des utilisateurs, tels que la navigation privée. Par conséquent, vérifiez si
indexedDBexiste et, dans le cas contraire, fournissez une solution de repli telle quelocalStorage.
Performance et bonnes pratiques
IndexedDB est rapide et puissant, mais ses performances peuvent beaucoup varier selon sa conception et la manière dont les données sont gérées. Selon le cas d’utilisation, l’optimisation peut être réalisée de la manière suivante :.
- Concevez le magasin d’objets en fonction de l’utilisation réelle. Par exemple, s'il y a beaucoup de lectures, fournissez des index ; s'il y a beaucoup d'écritures, gardez la conception des clés simple.
- Les grandes données binaires telles que les images et l’audio doivent être stockées comme des Blobs, ou gérées en utilisant l’API File ou les service workers si nécessaire. La compression peut également être envisagée si besoin.
- Gardez les transactions aussi courtes que possible et effectuez les traitements lourds en dehors de la transaction pour minimiser le temps de verrouillage.
- Les index peuvent accélérer les recherches, mais ralentir les insertions et les mises à jour ; créez donc uniquement ceux qui sont réellement nécessaires.
- Lorsqu’on traite de nombreuses petites données, les récupérer toutes avec
getAll()peut épuiser la mémoire. Vous pouvez réduire l’utilisation de la mémoire en divisant le traitement avec des curseurs.
Sécurité et confidentialité
Les données IndexedDB sont isolées par domaine et protocole conformément à la politique de même origine (same-origin). Concevez en partant du principe que les données peuvent être perdues si les utilisateurs suppriment les données du navigateur ou utilisent le mode privé.
Résumé et schémas de conception recommandés
Pour utiliser efficacement IndexedDB avec TypeScript, il est important de préparer les types et les processus asynchrones, de prêter attention à la gestion des versions et à la conception des transactions, et d’encapsuler les traitements communs afin d’améliorer la maintenabilité.
- Définir les types en TypeScript et encapsuler les opérations IndexedDB avec Promise/async/await améliore la sécurité et la lisibilité du code.
- Les modifications de schéma doivent utiliser la gestion de version avec
onupgradeneeded, et les traitements lourds doivent être différés lorsque possible. - Concevez les transactions pour qu’elles soient courtes et évitez les traitements asynchrones lourds dans une même transaction.
- En créant des classes d’encapsulation, vous pouvez réduire la redondance des traitements communs tels que la gestion des erreurs, la journalisation et la définition des types.
Vous pouvez suivre l'article ci-dessus avec Visual Studio Code sur notre chaîne YouTube. Veuillez également consulter la chaîne YouTube.