Back

Patterns pratiques de mémoïsation en JavaScript

Patterns pratiques de mémoïsation en JavaScript

Vous avez profilé votre application et découvert qu’une fonction s’exécute des milliers de fois avec des entrées identiques. La mémoïsation semble être la solution évidente. Mais avant d’envelopper tout dans un cache, vous devez savoir : une mémoïsation mal faite crée des bugs plus difficiles à trouver que les problèmes de performance initiaux.

Cet article couvre les patterns pratiques de mémoïsation JavaScript, les pièges courants de mémoïsation rencontrés par les développeurs JavaScript, et comment appliquer ces techniques en toute sécurité dans du code de production—incluant la mémoïsation asynchrone et les bonnes pratiques de useMemo dans React.

Points clés à retenir

  • La mémoïsation met en cache les résultats de fonction en fonction des arguments, échangeant de la mémoire contre de la vitesse—mais ne fonctionne de manière fiable qu’avec des fonctions pures et des arguments primitifs.
  • Les références d’objets causent des hits de cache obsolètes lorsque les données changent ; utilisez des bibliothèques comme fast-memoize pour un keying personnalisé ou limitez-vous aux primitives.
  • Les caches non bornés provoquent des fuites mémoire dans les applications longue durée ; implémentez une éviction LRU, une expiration TTL, ou limitez les caches aux cycles de vie des composants.
  • La mémoïsation asynchrone nécessite de mettre en cache les promesses immédiatement et de supprimer les entrées échouées pour éviter les requêtes dupliquées et permettre les nouvelles tentatives.
  • Le useMemo de React est une optimisation ciblée, pas un comportement par défaut—profilez d’abord et appliquez-le uniquement lorsque les calculs sont mesurément lents.

Ce que fait réellement la mémoïsation

La mémoïsation met en cache les résultats de fonction en fonction de leurs arguments. Appelez la fonction à nouveau avec les mêmes entrées, et vous obtenez le résultat mis en cache au lieu de recalculer.

JavaScript n’a pas de mémoïsation intégrée. Le TC39 a discuté de propositions (par exemple, Function.prototype.memo), mais rien n’est encore prêt pour la production (proposition). Vous devrez l’implémenter vous-même ou utiliser une bibliothèque.

Voici un pattern de base pour les fonctions à un seul argument :

function memoize(fn) {
  const cache = new Map()
  return (...args) => {
    const key = args[0]
    if (cache.has(key)) return cache.get(key)
    const result = fn(...args)
    cache.set(key, result)
    return result
  }
}

Cela fonctionne pour les arguments primitifs. Cela échoue de manière subtile pour tout le reste.

Le problème des références d’objets

Les objets sont mis en cache par référence, pas par valeur. Cela piège constamment les développeurs :

const memoizedFn = memoize(processData)

const config = { threshold: 10 }
memoizedFn(config) // Calcule
config.threshold = 20
memoizedFn(config) // Retourne un résultat en cache obsolète

Même référence signifie hit de cache, même si les données ont changé.

Certains développeurs tentent de corriger cela en utilisant JSON.stringify(args) comme clé de cache. Cela peut fonctionner pour des données simples, mais échoue sur les références circulaires, supprime les fonctions et symboles, et peut être lent pour les gros objets.

La solution : Ne mémoïsez que les fonctions avec des arguments primitifs, ou utilisez une bibliothèque comme fast-memoize qui prend en charge des résolveurs/sérialiseurs de clés personnalisés pour les cas plus complexes.

Quand la mémoïsation cause des problèmes

Fonctions impures

Mémoïser des fonctions impures crée des problèmes impossibles à déboguer :

// Ne jamais mémoïser ceci pour un cache long terme
const getData = memoize(() => {
  return fetch('/api/data').then(r => r.json())
})

Le premier appel met en cache la promesse. Chaque appel suivant retourne cette même promesse—même si les données du serveur ont changé.

La mémoïsation asynchrone n’est sûre que lorsque vous voulez intentionnellement dédupliquer des requêtes concurrentes, ou lorsque vous implémentez également une invalidation ou une expiration basée sur TTL (couvert ci-dessous).

Croissance illimitée du cache

Sans stratégies d’éviction de cache, votre cache grandit indéfiniment :

// Fuite mémoire en attente
const processUserInput = memoize((input) => expensiveOperation(input))

Chaque entrée unique s’ajoute au cache. Dans une application longue durée, cela provoque des fuites mémoire.

Solutions :

  • Définir une taille maximale de cache (éviction LRU)
  • Ajouter une expiration TTL (time-to-live)
  • Limiter les caches aux cycles de vie des composants
function memoizeWithLimit(fn, maxSize = 100) {
  const cache = new Map()
  return (...args) => {
    const key = args[0]
    if (cache.has(key)) return cache.get(key)
    if (cache.size >= maxSize) {
      const firstKey = cache.keys().next().value
      cache.delete(firstKey)
    }
    const result = fn(...args)
    cache.set(key, result)
    return result
  }
}

Mémoïsation asynchrone bien faite

La mémoïsation asynchrone nécessite une gestion spéciale pour les appels concurrents et les échecs :

function memoizeAsync(fn) {
  const cache = new Map()
  return async (...args) => {
    const key = args[0]
    if (cache.has(key)) return cache.get(key)
    
    const promise = fn(...args).catch(err => {
      cache.delete(key) // Ne pas mettre en cache les échecs
      throw err
    })
    
    cache.set(key, promise)
    return promise
  }
}

Mettez en cache la promesse immédiatement. Cela empêche les requêtes concurrentes dupliquées. Supprimez en cas d’échec pour que les nouvelles tentatives fonctionnent.

Bonnes pratiques useMemo dans React

useMemo et React.memo sont des optimisations ciblées, pas des comportements par défaut. Ils ajoutent de la complexité et peuvent nuire aux performances lorsqu’ils sont mal utilisés (voir la documentation officielle React : https://react.dev/reference/react/useMemo).

Utilisez useMemo quand :

  • Le calcul de données dérivées depuis props/state est mesurément lent
  • Vous passez des objets à des enfants mémoïsés

Évitez useMemo quand :

  • Le calcul est trivial
  • Vous n’avez pas mesuré de problème de performance
// Probablement inutile
const doubled = useMemo(() => value * 2, [value])

// Potentiellement utile
const sortedItems = useMemo(
  () => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
  [items]
)

La comparaison des dépendances de React utilise Object.is—égalité par référence. Les nouveaux littéraux d’objets cassent la mémoïsation à chaque rendu.

Le cadre de décision

Avant de mémoïser, demandez-vous :

  1. La fonction est-elle pure ? Pas d’effets de bord, les mêmes entrées produisent toujours les mêmes sorties.
  2. Est-elle réellement lente ? Profilez d’abord. Ne devinez pas.
  3. Les arguments sont-ils primitifs ou des références stables ? Les arguments objets nécessitent une gestion prudente.
  4. Quelle est la durée de vie du cache ? Les caches non bornés provoquent des fuites mémoire.

Conclusion

La mémoïsation échange de la mémoire contre de la vitesse. Assurez-vous d’obtenir un bon compromis. Commencez par profiler pour confirmer qu’un vrai problème de performance existe, puis appliquez la mémoïsation de manière sélective aux fonctions pures avec des arguments prévisibles. Implémentez des limites de cache pour éviter les fuites mémoire, gérez les opérations asynchrones avec soin, et rappelez-vous que le useMemo de React est un outil d’optimisation—pas un pattern par défaut. Lorsqu’elle est bien faite, la mémoïsation élimine les calculs redondants. Lorsqu’elle est mal faite, elle introduit des bugs subtils qui survivent aux gains de performance.

FAQ

Mémoïser des appels API est risqué car les données du serveur changent au fil du temps. Si vous mettez en cache la promesse, les appels suivants retournent des données obsolètes. Ne mémoïsez les appels API que lorsque vous voulez explicitement dédupliquer des requêtes concurrentes et que vous implémentez une invalidation de cache ou une expiration TTL pour rafraîchir les données périodiquement.

Les objets sont comparés par référence, pas par valeur. Si vous mutez un objet et appelez à nouveau la fonction mémoïsée, elle retourne le résultat en cache car la référence est inchangée. Utilisez des patterns de données immuables, créez de nouveaux objets au lieu de muter, ou sérialisez les arguments avec JSON.stringify pour les cas simples.

Profilez avant et après en utilisant les DevTools du navigateur ou les outils de profilage Node.js. Mesurez le temps d'exécution et l'utilisation mémoire. Si la fonction s'exécute rarement ou calcule rapidement, le surcoût de mémoïsation peut dépasser les gains. Le taux de hits de cache compte aussi—des taux faibles signifient de la mémoire gaspillée avec un bénéfice minimal.

Non. useMemo ajoute un surcoût pour le suivi et la comparaison des dépendances. Pour des calculs simples comme des opérations mathématiques de base ou de la concaténation de chaînes, le coût de mémoïsation dépasse le coût de calcul. Réservez useMemo pour les opérations coûteuses comme le tri de grands tableaux, le filtrage complexe, ou la création d'objets passés à des composants enfants mémoïsés.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay