Développement Web

IndexedDB avec Dexie.js 4 : stockage local riche en JavaScript

13 دقائق للقراءة

📍 Article principal : Application web installable hors-ligne : architecture complète pas à pas
Ce tutoriel approfondit le stockage local riche via IndexedDB et Dexie. Pour la vue d’ensemble de l’architecture, consulter d’abord l’article principal.

Pourquoi Dexie plutôt que l’API native d’IndexedDB

IndexedDB est une base de données orientée objet, transactionnelle, asynchrone et indexée, disponible nativement dans tous les navigateurs modernes. C’est l’outil de stockage le plus puissant côté client : il accepte n’importe quel objet JavaScript sérialisable par l’algorithme de clonage structuré, supporte des transactions ACID sur plusieurs magasins, permet de définir des index secondaires et d’exécuter des requêtes par plage. Le problème : son API basée sur des objets IDBRequest avec callbacks onsuccess/onerror est verbeuse, peu intuitive, et propice aux bugs subtils sur la gestion des transactions.

Dexie.js est une couche d’abstraction très fine au-dessus d’IndexedDB. Elle ne fait que ce qu’IndexedDB permet déjà, sans ajouter de fonctionnalités exotiques ni de surcoût mesurable, mais elle expose une API à base de promesses, de schémas déclaratifs et de requêtes fluentes. Le code passe de quatre-vingts lignes à dix pour les opérations courantes. Dexie est maintenue depuis 2014, atteint sa version 4.4.2, ne dépend de rien d’autre que du navigateur, et pèse environ 30 ko gzippé pour la build complète.

Prérequis

  • Connaissance des promesses ES2017 (async/await)
  • Un projet JavaScript moderne, idéalement avec un bundler (Vite, Webpack, Rollup) ou en ES modules natifs
  • Niveau intermédiaire en JavaScript
  • Temps estimé : 50 minutes

Étape 1 — Installer Dexie

L’installation se fait via npm en une commande. Dexie n’a aucune dépendance externe, ce qui rend son intégration triviale même dans des projets sensibles à la taille de bundle.

npm install dexie@4.4.2

Une fois installé, importez la classe principale depuis votre point d’entrée applicatif. L’import par défaut expose la classe Dexie qui sert de constructeur pour chaque base. Une seule base par origine est généralement suffisante ; le découpage logique se fait via les magasins d’objets à l’intérieur d’une même base.

import Dexie from 'dexie';

Pour un usage hors bundler, la bibliothèque expose aussi une version UMD sur les CDN classiques (https://unpkg.com/dexie@4.4.2/dist/dexie.min.js), à charger via une balise script classique. La variable globale Dexie est alors disponible.

Étape 2 — Déclarer le schéma

Dexie utilise un schéma déclaratif passé à la méthode version(n).stores(...). Chaque entrée décrit un magasin d’objets avec sa clé primaire et ses index secondaires. La syntaxe est compacte : le premier champ est la clé primaire (préfixée ++ pour auto-incrément), les suivants sont les index. Un préfixe & marque un index unique.

const db = new Dexie('AppOfflineDB');

db.version(1).stores({
  produits: '++id, &reference, categorieId, prix, dateAjout',
  categories: '++id, &slug, nom',
  brouillons: '++id, type, dateModification',
  pendingMutations: '++id, type, dateCreation'
});

Quatre magasins sont déclarés ici : produits (avec une référence unique et des index sur catégorie, prix et date), categories (avec un slug unique), brouillons (pour les éditions en cours) et pendingMutations (la file d’attente d’actions à synchroniser). Notez que les attributs non listés dans le schéma sont quand même stockés — seuls les champs indexés y figurent, le reste de l’objet est conservé tel quel.

Étape 3 — Migrations de schéma

L’évolution d’un schéma se fait en ajoutant des appels version(n) successifs. Dexie applique automatiquement les migrations entre la version installée chez l’utilisateur et la version courante du code. Ne jamais modifier une version déjà publiée : toujours ajouter une nouvelle.

db.version(1).stores({
  produits: '++id, &reference, categorieId, prix, dateAjout',
  categories: '++id, &slug, nom'
});

db.version(2).stores({
  produits: '++id, &reference, categorieId, prix, dateAjout, stock',
  categories: '++id, &slug, nom',
  brouillons: '++id, type, dateModification'
}).upgrade(tx => {
  return tx.table('produits').toCollection().modify(p => {
    p.stock = 0;
  });
});

La version 2 ajoute l’index stock à produits, crée le magasin brouillons, et exécute une fonction upgrade qui initialise stock à 0 sur tous les enregistrements existants. La transaction tx est automatiquement créée par Dexie ; on ne fait que lire et modifier. Si la migration échoue, IndexedDB la rejoue à la prochaine ouverture — il faut donc qu’elle soit idempotente.

Étape 4 — CRUD basique

Toutes les opérations sont asynchrones et retournent des promesses. Les méthodes principales sur un magasin sont add, put, get, delete, where, toArray, count. Voici un exemple complet qui couvre les cas habituels.

// Ajout — échec si la clé existe déjà
const nouveauId = await db.produits.add({
  reference: 'PROD-001',
  nom: 'Article A',
  prix: 1500,
  categorieId: 3,
  dateAjout: new Date(),
  stock: 12
});

// Mise à jour ou insertion — remplace si existe
await db.produits.put({
  id: nouveauId,
  reference: 'PROD-001',
  nom: 'Article A renommé',
  prix: 1800,
  categorieId: 3,
  dateAjout: new Date(),
  stock: 10
});

// Lecture par clé primaire
const produit = await db.produits.get(nouveauId);

// Suppression
await db.produits.delete(nouveauId);

// Requête par index
const articlesCategorie3 = await db.produits
  .where('categorieId').equals(3)
  .toArray();

La différence entre add et put est subtile mais importante : add échoue si la clé primaire est déjà utilisée (utile pour détecter les doublons), tandis que put écrase silencieusement. En cas de doute, préférez add et traitez l’erreur ConstraintError explicitement plutôt que de masquer un bug avec put.

Étape 5 — Requêtes complexes

Le module WhereClause de Dexie offre une API fluente pour exprimer des requêtes par plage, par préfixe, par jeu de valeurs, le tout en utilisant les index déclarés dans le schéma. Sans index, une requête équivaut à un parcours complet en mémoire — toujours possible mais coûteux dès quelques milliers d’enregistrements.

// Produits dont le prix est entre 1000 et 5000
const milieuGamme = await db.produits
  .where('prix').between(1000, 5000, true, true)
  .sortBy('prix');

// Produits ajoutés dans les 7 derniers jours
const ilYa7Jours = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const recents = await db.produits
  .where('dateAjout').above(ilYa7Jours)
  .reverse()
  .limit(20)
  .toArray();

// Catégories dont le slug commence par "elec"
const electro = await db.categories
  .where('slug').startsWith('elec')
  .toArray();

// Multi-conditions — filter applique en mémoire après l'index
const electroEnStock = await db.produits
  .where('categorieId').equals(1)
  .and(p => p.stock > 0)
  .sortBy('prix');

Les opérateurs between, above, below, equals, anyOf, startsWith exploitent l’index directement et sont rapides même sur de grandes tables. La méthode .and(fn) applique un filtre supplémentaire en mémoire après la requête indexée : elle est utile pour combiner plusieurs conditions mais ne bénéficie pas de l’index. Pour les requêtes très fréquentes sur deux champs, déclarer un index composé ('[categorieId+stock]') permet à Dexie de tout faire au niveau index.

Étape 6 — Transactions explicites

Dexie crée automatiquement une transaction implicite pour chaque opération unitaire. Pour grouper plusieurs opérations qui doivent réussir ou échouer ensemble, il faut une transaction explicite. La méthode db.transaction prend trois arguments : le mode ('r' lecture seule, 'rw' lecture-écriture), la liste des magasins concernés, et une fonction qui exécute les opérations.

await db.transaction('rw', db.produits, db.categories, async () => {
  const cat = await db.categories.add({ slug: 'electronique', nom: 'Électronique' });
  await db.produits.bulkAdd([
    { reference: 'E-001', nom: 'Casque', prix: 5000, categorieId: cat, stock: 50, dateAjout: new Date() },
    { reference: 'E-002', nom: 'Câble', prix: 800, categorieId: cat, stock: 200, dateAjout: new Date() }
  ]);
});

Si l’une des opérations échoue, l’ensemble est annulé : la catégorie n’est pas créée si l’ajout des produits échoue. Cette propriété est cruciale pour maintenir la cohérence de la base. Dexie détecte automatiquement les opérations à inclure dans la transaction au sein de la fonction passée — il ne faut pas y faire d’opérations sur des magasins non déclarés, sinon une erreur est levée immédiatement.

Étape 7 — Observabilité et synchronisation UI

Une application qui montre une liste depuis IndexedDB doit savoir quand cette liste change pour rafraîchir l’affichage. Dexie expose plusieurs mécanismes. Le plus simple est l’API liveQuery, qui retourne un Observable se mettant à jour automatiquement à chaque modification d’un magasin concerné.

import { liveQuery } from 'dexie';

const subscription = liveQuery(() =>
  db.produits.where('stock').above(0).toArray()
).subscribe({
  next: produits => mettreAJourLaListe(produits),
  error: err => console.error('LiveQuery error', err)
});

// Plus tard, lors du démontage du composant
subscription.unsubscribe();

Cette API s’intègre nativement avec React via useLiveQuery (paquet dexie-react-hooks) et avec Vue, Svelte ou Solid via leurs primitives de réactivité respectives. Le composant se réabonne automatiquement et l’UI reste synchronisée sans glue code manuel. La performance est correcte tant que les requêtes restent sous les quelques milliers d’enregistrements ; au-delà, il vaut mieux paginer.

Étape 8 — Gérer les conflits de version entre onglets

Quand un utilisateur a plusieurs onglets ouverts et que le code déploie une nouvelle version du schéma, le nouvel onglet ne pourra pas ouvrir la base tant que les anciens onglets maintiennent une connexion à l’ancienne version. IndexedDB émet un événement versionchange qu’il faut écouter pour fermer proprement la connexion existante.

db.on('versionchange', () => {
  console.log('Nouvelle version détectée, fermeture des connexions existantes');
  db.close();
  alert('Mise à jour de l\'application — veuillez recharger cette page');
});

L’utilisateur est averti et l’ancien onglet libère la connexion, permettant aux nouveaux onglets de migrer. Sans ce handler, l’ouverture de la base se bloque silencieusement et l’application devient inutilisable sur les onglets non rechargés. Le test à faire systématiquement : ouvrir deux onglets, déployer une nouvelle version, vérifier que le scénario se déroule sans blocage.

Étape 9 — File d’attente d’actions à synchroniser

Le magasin pendingMutations déclaré au début prend tout son sens quand l’application doit fonctionner hors connexion. Le principe : chaque action utilisateur qui modifie l’état serveur (création, mise à jour, suppression) est d’abord enregistrée localement en attente, puis envoyée au serveur dès que possible. Voici la primitive d’enqueue à utiliser depuis le code applicatif.

async function enfilerMutation(type, payload) {
  const id = await db.pendingMutations.add({
    type,
    payload,
    dateCreation: new Date(),
    tentatives: 0
  });
  // Demander au navigateur de programmer une synchronisation
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const reg = await navigator.serviceWorker.ready;
    await reg.sync.register('flush-mutations');
  } else {
    // Repli : tenter immédiatement si en ligne
    if (navigator.onLine) viderFileImmediatement();
  }
  return id;
}

Cette fonction stocke la mutation, puis demande une synchronisation différée via l’API Background Sync. Si l’API n’est pas disponible (Firefox, Safari), elle déclenche immédiatement le flush si le navigateur se déclare en ligne. Le compteur tentatives permet plus tard de limiter les retries et d’abandonner les actions qui échouent en boucle. La logique côté service worker est détaillée dans le tutoriel sur la synchronisation différée.

Étape 10 — Nettoyer pour libérer de l’espace

Les bases IndexedDB accumulent vite. Pour des données qui ont une durée de vie naturelle — un cache de réponses API par exemple — il est sain de purger périodiquement les enregistrements anciens. Une routine appelée au démarrage de l’application suffit.

async function purgerAnciensEnregistrements() {
  const seuil = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 jours
  const count = await db.brouillons
    .where('dateModification').below(seuil)
    .delete();
  console.log(`Purgé ${count} brouillons obsolètes`);
}
window.addEventListener('load', purgerAnciensEnregistrements);

La méthode .delete() sur une collection retourne le nombre d’enregistrements supprimés. Cette purge est non-bloquante et s’exécute en arrière-plan. Si vous stockez aussi des fichiers volumineux, surveiller régulièrement l’usage via navigator.storage.estimate() et déclencher une purge plus agressive quand l’usage dépasse 80 % du quota évite de se faire évincer par le navigateur lors d’une pression disque.

Erreurs fréquentes

Erreur Cause Solution
« VersionError: The requested version (n) is less than the existing version (m) » Code déployé avec un numéro de version inférieur à celui déjà en base chez l’utilisateur Toujours incrémenter, jamais décrémenter ; ne pas reculer numéro de version même en rollback
Transaction qui se ferme avant la fin des opérations asynchrones Promesse externe à Dexie attendue dans une transaction (par exemple fetch()) Ne faire que des opérations Dexie dans une transaction ; faire les fetch() avant ou après
« DataCloneError » Tentative de stocker un objet non sérialisable (fonction, classe avec méthodes) Stocker uniquement des objets simples (POJO), Date, Blob, ArrayBuffer
Performance qui s’effondre sur grosses tables Requête sans index sur le champ de filtre Ajouter un index dans le schéma et migrer ; vérifier le plan via la console DevTools
Base vide après réouverture en navigation privée Mode incognito purge IndexedDB à la fermeture Pas de solution — informer l’utilisateur ou détecter le mode privé

Tutoriels frères

Pour aller plus loin

Questions fréquentes

Combien de données peut-on stocker dans IndexedDB ?
Plusieurs gigaoctets sur un appareil moderne avec espace libre. Le quota global d’une origine est d’environ 60 % du disque libre dans Chrome. navigator.storage.estimate() donne la quantité utilisée et disponible en temps réel.

Dexie fonctionne-t-il dans un service worker ?
Oui, sans aucune adaptation. Importer Dexie comme dans une page et ouvrir la base. Toutes les pages et tous les service workers d’une même origine partagent la même base IndexedDB.

Comment exporter ou importer toute la base ?
Le paquet officiel dexie-export-import sérialise toutes les tables en JSON ou les recharge depuis un blob. Utile pour les sauvegardes, les migrations entre versions majeures, ou les fonctions « exporter mes données ».

Faut-il chiffrer les données stockées ?
Par défaut, IndexedDB n’est pas chiffré au repos — les données sont protégées par le compte utilisateur du système d’exploitation. Pour des données sensibles (jetons d’API, secrets), utiliser SubtleCrypto pour chiffrer avant insertion. Une clé dérivée d’un mot de passe utilisateur via PBKDF2 ou Argon2 est la solution la plus sûre.

Quelle taille maximale par enregistrement individuel ?
Il n’y a pas de limite stricte. Des blobs de plusieurs dizaines de mégaoctets passent sans problème. Cependant, l’algorithme de clonage structuré peut être lent au-delà de quelques mégaoctets — pour des fichiers volumineux, préférer stocker en Blob plutôt qu’en chaîne ou tableau d’octets.

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité