Créez un mini ORM JavaScript simple et réutilisable pour IndexedDB. API fluide (insert, find, update, delete), gestion des transactions, versioning et exemples pratiques pour stocker des données localement dans le navigateur.
Introduction à IndexedDB
IndexedDB est une base de données NoSQL intégrée aux navigateurs modernes. Elle permet de stocker de grandes quantités de données structurées localement, avec des index pour des recherches rapides.
Contrairement à localStorage qui est limité à ~5-10 Mo de données textuelles, IndexedDB peut stocker des centaines de mégaoctets voire plusieurs gigaoctets selon le navigateur.
- Base de données orientée objet (objets JavaScript)
- Support des transactions ACID
- API asynchrone (basée sur des événements)
- Stockage hors-ligne persistant
- Index multiples pour recherches efficaces
Pourquoi utiliser IndexedDB ?
Cas d'usage typiques
- PWA et applications offline : Synchroniser les données quand la connexion revient
- Cache applicatif : Améliorer les performances en évitant des appels API répétés
- Applications riches en données : Tableaux de bord, éditeurs, gestionnaires de fichiers
- Formulaires complexes : Sauvegarder automatiquement les brouillons
- Jeux web : Sauvegardes locales, assets, scores
Comparaison avec d'autres solutions
| Solution | Capacité | Type de données | Performance |
|---|---|---|---|
| localStorage | ~5-10 Mo | String uniquement | Synchrone (bloquant) |
| sessionStorage | ~5-10 Mo | String uniquement | Synchrone (bloquant) |
| IndexedDB | 50 Mo - 1+ Go | Objets, Blob, ArrayBuffer | Asynchrone (non-bloquant) |
| Cache API | Variable | Requêtes/Réponses HTTP | Asynchrone |
Limites de l'API native
L'API IndexedDB native est puissante mais très verbeuse et complexe à utiliser. Voici un exemple pour simplement ajouter un objet :
// API native - trop verbeux !
const request = indexedDB.open('myDB', 1);
request.onsuccess = (event) => {
const db = event.target.result;
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const addRequest = store.add({ id: 1, name: 'Alice' });
addRequest.onsuccess = () => {
console.log('Utilisateur ajouté');
};
addRequest.onerror = () => {
console.error('Erreur');
};
};
request.onerror = () => {
console.error('Erreur ouverture DB');
};
Pour une simple opération CRUD, il faut gérer :
- Ouverture de la base de données
- Création de transactions
- Accès aux object stores
- Gestion des callbacks success/error
- Gestion du versioning
Architecture du mini ORM
Notre ORM va fournir une API simple et chainable :
// API cible - simple et moderne
const db = new MiniORM('myApp', 1);
// CRUD en une ligne
await db.table('users').insert({ id: 1, name: 'Alice' });
const user = await db.table('users').find(1);
await db.table('users').update(1, { name: 'Bob' });
await db.table('users').delete(1);
// Requêtes avancées
const admins = await db.table('users')
.where('role', 'admin')
.orderBy('name')
.limit(10)
.getAll();
Classes principales
- MiniORM : Classe principale, gestion de la connexion
- Table : Représente une table (object store)
- Query : Builder de requêtes avec méthodes chainables
Implémentation complète
Classe MiniORM
class MiniORM {
constructor(dbName, version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
this.schemas = new Map();
}
// Définir le schéma d'une table
defineTable(tableName, options = {}) {
this.schemas.set(tableName, {
keyPath: options.keyPath || 'id',
autoIncrement: options.autoIncrement !== false,
indexes: options.indexes || []
});
return this;
}
// Ouvrir la connexion
async connect() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Créer les object stores selon les schémas
this.schemas.forEach((schema, tableName) => {
if (!db.objectStoreNames.contains(tableName)) {
const store = db.createObjectStore(tableName, {
keyPath: schema.keyPath,
autoIncrement: schema.autoIncrement
});
// Créer les index
schema.indexes.forEach(index => {
store.createIndex(
index.name,
index.keyPath,
{ unique: index.unique || false }
);
});
}
});
};
});
}
// Accéder à une table
table(tableName) {
if (!this.db) {
throw new Error('Database not connected. Call connect() first.');
}
return new Table(this.db, tableName);
}
// Fermer la connexion
close() {
if (this.db) {
this.db.close();
this.db = null;
}
}
}
Classe Table
class Table {
constructor(db, tableName) {
this.db = db;
this.tableName = tableName;
}
// INSERT
async insert(data) {
return this._transaction('readwrite', (store) => {
return store.add(data);
});
}
// FIND (par clé primaire)
async find(key) {
return this._transaction('readonly', (store) => {
return store.get(key);
});
}
// UPDATE
async update(key, data) {
const existing = await this.find(key);
if (!existing) {
throw new Error(`Record with key ${key} not found`);
}
const updated = { ...existing, ...data };
return this._transaction('readwrite', (store) => {
return store.put(updated);
});
}
// DELETE
async delete(key) {
return this._transaction('readwrite', (store) => {
return store.delete(key);
});
}
// GET ALL
async getAll() {
return this._transaction('readonly', (store) => {
return store.getAll();
});
}
// COUNT
async count() {
return this._transaction('readonly', (store) => {
return store.count();
});
}
// CLEAR (vider la table)
async clear() {
return this._transaction('readwrite', (store) => {
return store.clear();
});
}
// WHERE (requête par index)
where(indexName, value) {
return new Query(this.db, this.tableName, indexName, value);
}
// Méthode privée pour gérer les transactions
_transaction(mode, callback) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.tableName], mode);
const store = transaction.objectStore(this.tableName);
const request = callback(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
Classe Query (requêtes avancées)
class Query {
constructor(db, tableName, indexName, value) {
this.db = db;
this.tableName = tableName;
this.indexName = indexName;
this.value = value;
this._limit = null;
this._orderDirection = 'next';
}
// Limiter les résultats
limit(count) {
this._limit = count;
return this;
}
// Ordre décroissant
orderByDesc() {
this._orderDirection = 'prev';
return this;
}
// Exécuter la requête
async getAll() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.tableName], 'readonly');
const store = transaction.objectStore(this.tableName);
const index = store.index(this.indexName);
const request = index.openCursor(
IDBKeyRange.only(this.value),
this._orderDirection
);
const results = [];
let count = 0;
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor && (!this._limit || count < this._limit)) {
results.push(cursor.value);
count++;
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
// Premier résultat uniquement
async first() {
const results = await this.limit(1).getAll();
return results[0] || null;
}
}
Utilisation : opérations CRUD
Configuration initiale
// Initialiser le mini ORM
const db = new MiniORM('monApp', 1);
// Définir les tables
db.defineTable('users', {
keyPath: 'id',
autoIncrement: true,
indexes: [
{ name: 'email', keyPath: 'email', unique: true },
{ name: 'role', keyPath: 'role' }
]
});
db.defineTable('posts', {
keyPath: 'id',
autoIncrement: true,
indexes: [
{ name: 'userId', keyPath: 'userId' },
{ name: 'status', keyPath: 'status' }
]
});
// Connexion
await db.connect();
CREATE - Insérer des données
// Insérer un utilisateur
const userId = await db.table('users').insert({
name: 'Alice Dupont',
email: 'alice@example.com',
role: 'admin',
createdAt: new Date()
});
console.log('User ID:', userId);
// Insérer plusieurs posts
const posts = [
{ userId: 1, title: 'Premier post', status: 'published' },
{ userId: 1, title: 'Brouillon', status: 'draft' }
];
for (const post of posts) {
await db.table('posts').insert(post);
}
READ - Lire des données
// Récupérer par ID
const user = await db.table('users').find(1);
console.log('User:', user);
// Récupérer tous les utilisateurs
const allUsers = await db.table('users').getAll();
console.log('Total users:', allUsers.length);
// Requête avec index
const admins = await db.table('users')
.where('role', 'admin')
.getAll();
// Requête avec limite
const recentPosts = await db.table('posts')
.where('status', 'published')
.orderByDesc()
.limit(10)
.getAll();
// Compter les entrées
const userCount = await db.table('users').count();
console.log('Nombre d\'utilisateurs:', userCount);
UPDATE - Mettre à jour
// Mise à jour partielle
await db.table('users').update(1, {
name: 'Alice Martin',
updatedAt: new Date()
});
// Mise à jour complète (PUT)
const user = await db.table('users').find(1);
user.lastLoginAt = new Date();
await db.table('users').update(1, user);
DELETE - Supprimer
// Supprimer un utilisateur
await db.table('users').delete(1);
// Vider toute la table
await db.table('posts').clear();
// Fermer la connexion
db.close();
Transactions et versioning
Transactions manuelles
Pour des opérations complexes nécessitant l'atomicité :
class MiniORM {
// ... code précédent ...
// Transaction multi-tables
async transaction(tableNames, mode, callback) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(tableNames, mode);
const stores = tableNames.map(name => tx.objectStore(name));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
try {
callback(stores);
} catch (error) {
reject(error);
}
});
}
}
// Utilisation : transférer un post d'un user à un autre
await db.transaction(['users', 'posts'], 'readwrite', ([usersStore, postsStore]) => {
const getUserReq = usersStore.get(oldUserId);
getUserReq.onsuccess = () => {
const user = getUserReq.result;
user.postCount--;
usersStore.put(user);
};
const getPostReq = postsStore.get(postId);
getPostReq.onsuccess = () => {
const post = getPostReq.result;
post.userId = newUserId;
postsStore.put(post);
};
});
Gestion du versioning
Migrer la structure de la base lors des montées de version :
const db = new MiniORM('monApp', 2); // Version 2
db.defineTable('users', {
keyPath: 'id',
autoIncrement: true,
indexes: [
{ name: 'email', keyPath: 'email', unique: true },
{ name: 'role', keyPath: 'role' },
{ name: 'premium', keyPath: 'premium' } // Nouvel index v2
]
});
// Migration automatique lors du onupgradeneeded
await db.connect();
// Migrer les données existantes si nécessaire
const users = await db.table('users').getAll();
for (const user of users) {
if (!user.premium) {
await db.table('users').update(user.id, { premium: false });
}
}
Exemples pratiques
Cache d'API avec expiration
class CacheManager {
constructor(db, expirationMs = 3600000) { // 1h par défaut
this.db = db;
this.expirationMs = expirationMs;
}
async get(key) {
const cached = await this.db.table('cache').find(key);
if (!cached) return null;
// Vérifier l'expiration
if (Date.now() - cached.timestamp > this.expirationMs) {
await this.db.table('cache').delete(key);
return null;
}
return cached.data;
}
async set(key, data) {
await this.db.table('cache').insert({
id: key,
data: data,
timestamp: Date.now()
});
}
}
// Utilisation
const cache = new CacheManager(db);
async function fetchUsers() {
const cached = await cache.get('users');
if (cached) return cached;
const users = await fetch('/api/users').then(r => r.json());
await cache.set('users', users);
return users;
}
Sauvegarde auto de formulaire
class FormAutosave {
constructor(db, formId) {
this.db = db;
this.formId = formId;
this.debounceTimer = null;
}
// Sauvegarder avec debounce
save(formData) {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => {
await this.db.table('formDrafts').insert({
id: this.formId,
data: formData,
savedAt: new Date()
});
console.log('Draft saved');
}, 1000);
}
// Restaurer
async restore() {
const draft = await this.db.table('formDrafts').find(this.formId);
return draft ? draft.data : null;
}
// Nettoyer après soumission
async clean() {
await this.db.table('formDrafts').delete(this.formId);
}
}
// Utilisation
const formSaver = new FormAutosave(db, 'contact-form');
document.getElementById('myForm').addEventListener('input', (e) => {
const formData = new FormData(e.target.form);
const data = Object.fromEntries(formData);
formSaver.save(data);
});
// Restaurer au chargement
window.addEventListener('DOMContentLoaded', async () => {
const draft = await formSaver.restore();
if (draft && confirm('Restaurer le brouillon ?')) {
// Remplir le formulaire avec les données sauvegardées
Object.entries(draft).forEach(([key, value]) => {
const input = document.querySelector(`[name="${key}"]`);
if (input) input.value = value;
});
}
});
Alternatives : Dexie.js et autres
Si vous avez besoin de fonctionnalités avancées, considérez ces bibliothèques :
Dexie.js (recommandé)
La bibliothèque la plus populaire pour IndexedDB, avec une API moderne et des fonctionnalités riches.
// npm install dexie
import Dexie from 'dexie';
const db = new Dexie('monApp');
db.version(1).stores({
users: '++id, email, role',
posts: '++id, userId, status'
});
// CRUD simplifié
await db.users.add({ name: 'Alice', email: 'alice@example.com' });
const user = await db.users.get(1);
await db.users.where('role').equals('admin').toArray();
await db.users.update(1, { name: 'Bob' });
await db.users.delete(1);
Autres alternatives
| Bibliothèque | Taille | Points forts |
|---|---|---|
| Dexie.js | ~20 KB | API moderne, TypeScript, observables, hooks |
| idb | ~1 KB | Wrapper minimal basé sur Promises |
| localForage | ~10 KB | API simple type localStorage, fallback auto |
| PouchDB | ~145 KB | Compatible CouchDB, sync multi-device |
Bonnes pratiques
- Versionning : Incrémentez la version à chaque changement de schéma
- Index pertinents : Créez des index sur les champs utilisés dans les requêtes
- Gestion d'erreurs : Toujours wrapper les opérations dans try/catch
- Fermer les connexions : Appelez
db.close()quand terminé - Limiter les transactions : Évitez les transactions longues (bloquent l'accès)
- Pas de données sensibles : IndexedDB n'est pas chiffré (visible DevTools)
- Quota de stockage : Vérifier
navigator.storage.estimate() - Nettoyage régulier : Supprimer les données expirées périodiquement
- Backup important : Ne pas considérer IndexedDB comme stockage permanent
Vérifier le quota disponible
if ('storage' in navigator && 'estimate' in navigator.storage) {
const { quota, usage } = await navigator.storage.estimate();
console.log(`Utilisé: ${(usage / 1024 / 1024).toFixed(2)} MB`);
console.log(`Total: ${(quota / 1024 / 1024).toFixed(2)} MB`);
console.log(`Disponible: ${((quota - usage) / 1024 / 1024).toFixed(2)} MB`);
}
Gestion des erreurs complète
try {
await db.connect();
const user = await db.table('users').find(1);
console.log('User:', user);
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.error('Quota de stockage dépassé');
// Nettoyer les anciennes données
} else if (error.name === 'VersionError') {
console.error('Conflit de version de base de données');
} else {
console.error('Erreur IndexedDB:', error);
}
} finally {
db.close();
}
Conclusion
IndexedDB est une solution puissante pour le stockage local dans les applications web modernes, mais son API native est complexe. Ce mini ORM simplifie drastiquement son utilisation tout en restant léger et sans dépendances.
Pour des projets plus complexes, n'hésitez pas à utiliser Dexie.js qui offre des fonctionnalités avancées (observables, relations, migrations automatiques).