Accueil / Articles / L’outil secret de JavaScript : symboles, WeakMaps, proxies et générateurs

L’outil secret de JavaScript : symboles, WeakMaps, proxies et générateurs

Découvrez comment les symboles, WeakMap/WeakSet, Proxy/Reflect, FinalizationRegistry et les générateurs fonctionnent en profondeur pour éviter les fuites de mémoire et permettre le métaprogrammation au niveau du framework.

2046 mots

Découvrir le moteur caché derrière vos frameworks préférés et éliminer définitivement les fuites de mémoire

La plupart du temps, le développement en JavaScript s’appuie sur un ensemble d’outils de base bien connus : const, let, les fonctions flèche, map/filter, la déstructuration des tableaux, ainsi que async/await. Ce sous-ensemble courant permet de gérer la grande majorité des projets que l’on crée.

Cependant, la spécification ECMAScript comprend également un ensemble d’outils moins visibles : des types primitifs, des conteneurs de données spécialisés et des mécanismes de métaprogrammation de bas niveau qui n’apparaissent presque jamais dans les tutoriels introductifs. Les auteurs de frameworks et de bibliothèques s’en servent constamment pour éviter les fuites de mémoire, contourner les conflits de noms, comprendre le comportement interne du moteur et maintenir la stabilité de gros projets. Une fois que vous les comprenez, vous commencerez à les voir partout dans les dépendances que vous utilisez déjà.

Apprendre à utiliser ces fonctionnalités change la manière dont on envisage la durée de vie des objets, les limites de l’encapsulation et les performances en temps de exécution. Ci-dessous est présenté un aperçu des capacités dont vous avez presque certainement bénéficié dans du code en production sans savoir qu’elles existaient.

1. Symboles : des clés qui ne se chevauchent jamais

Symbol, ajouté en ES6, est un type primitif qui se trouve aux côtés de string, number, boolean, null, undefined et bigint.

Chaque appel à Symbol() génère une valeur entièrement nouvelle et unique. Même deux symboles créés avec la même description ne sont jamais égaux l’un à l’autre :

const id1 = Symbol('id');
const id2 = Symbol('id');

console.log(id1 === id2); // false

Le texte que vous passez à Symbol('id') n’est rien de plus qu’une étiquette optionnelle pour les résultats de débogage — il n’a aucune influence sur l’identité du symbole.

Pourquoi les symboles existent-ils d’abord ?

Au moment où les symboles n’existaient pas, les clés des objets devaient être des chaînes de caractères. Cela créait un vrai risque : si vous écriviez une bibliothèque qui attachait des métadonnées internes à un objet appartenant à quelqu’un d’autre, vous pouviez facilement altérer une propriété qui existait déjà là :

// Risky: Another script might already use `isProcessed`
user.isProcessed = true;

Les symboles évitent complètement ce problème, car il est impossible pour un autre morceau de code de générer par erreur le même symbole :

const TRACKING_ID = Symbol('trackingId');

const user = {
  name: 'Sarah',
  role: 'Admin'
};

// Safe: Nothing can clash with this exact key
user[TRACKING_ID] = 'txn_89412';

Le manteau d’invisibilité

Les propriétés identifiées par un symbole sont ignorées par les outils habituels de réflexion et de sérialisation :

  • Les boucles for...in les ignorent.
  • Object.keys(user) les exclut également.
  • JSON.stringify(user) les supprime complètement.
console.log(Object.keys(user)); // ['name', 'role']
console.log(JSON.stringify(user)); // '{"name":"Sarah","role":"Admin"}'

Cela dit, les clés de symboles ne sont pas vraiment secrètes — appeler Object.getOwnPropertySymbols(user) permettra malgré tout de les découvrir. Mais à des fins d’itération et de sérialisation ordinaires, elles restent cachées, ce qui en fait des choix idéaux pour les flags internes, les valeurs mémorisées et les métadonnées au niveau des plugins qui ne devraient pas filtrer vers une API publique.

Symboles bien connus : accès aux fonctionnalités internes du langage

Le langage dispose également d’un ensemble de symboles intégrés, appelés symboles bien connus, exposés en tant que propriétés statiques sur l’objet Symbol. Ceux-ci vous donnent un accès direct aux protocoles sur lesquels repose le moteur lui-même.

Itération personnalisée via Symbol.iterator

Vous pouvez faire fonctionner n’importe quel objet ordinaire avec for...of en implémentant vous-même ce symbole :

const inventory = {
  items: ['Keyboard', 'Monitor', 'Desk Pad'],
  [Symbol.iterator]() {
    let index = 0;
    return {
      next: () => {
        if (index < this.items.length) {
          return { value: this.items[index++], done: false };
        }
        return { done: true };
      }
    };
  }
};

for (const item of inventory) {
  console.log(item); // Prints: Keyboard, Monitor, Desk Pad
}

Symbol.toPrimitive

Cela vous permet de décider précisément ce qui se passe lorsque votre objet est converti en chaîne de caractères ou en nombre :

const money = {
  amount: 250,
  currency: 'USD',
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `${this.amount} ${this.currency}`;
    return this.amount; // default
  }
};

console.log(+money + 50); // 300
console.log(`${money}`);  // "250 USD"

2. WeakMap et WeakSet : nettoyage de la mémoire sans suivi manuel

Pour comprendre pourquoi WeakMap est important, il est utile de first examiner la manière dont un Map ordinaire gère la mémoire.

Map normal conserve une référence forte pour chaque clé et chaque valeur qu’il contient. Cela signifie que tant que le Map lui-même reste actif, aucune de ses clés ne peut être collectée comme déchet — même après que toutes les autres parties de votre application ont cessé de faire référence à elles.

let cache = new Map();
let element = document.querySelector('#heavy-widget');

cache.set(element, { clicks: 0 });

// Later, the DOM node is removed:
element.remove();
element = null;

// Problem: The DOM node is still stuck in memory because `cache` holds it.

C’est une source courante de fuites mémoire du côté client, en particulier dans les applications single-page où les utilisateurs passent d’une vue à l’autre sans rechargement complet de la page pour réinitialiser la mémoire.

C’est là que WeakMap entre en jeu. Il ne conserve que des références faibles vers ses clés, ce qui a deux conséquences importantes :

  1. Les clés doivent être des objets (ou, dans les moteurs modernes, des symboles non enregistrés) — les primitives telles que des chaînes de caractères ou des nombres ne peuvent pas servir de clés.
  2. Lorsqu’aucun autre élément du programme ne fait plus référence à un objet-clé, le collecteur de déchets peut le récupérer, et l’entrée correspondante dans WeakMap disparaît avec lui.
const metadataStore = new WeakMap();

function setupWidget(domNode) {
  metadataStore.set(domNode, { initializedAt: Date.now() });
}

// When domNode is removed from the DOM and its variable goes out of scope,
// the WeakMap entry is garbage-collected automatically.

Les compromis intégrés dans la conception

Puisque le nettoyage des déchets s’exécute à des moments imprévisibles, choisis par le navigateur et non par votre code, WeakMap limite délibérément ce que vous pouvez en faire :

  • Il n’est pas itérable : pas de .forEach(), pas de for...of, pas de .keys().
  • Il n’y a pas de propriété .size, ce qui vous empêche de savoir combien d’entrées existent actuellement.
  • Seules quatre méthodes sont disponibles : .get(), .set(), .has() et .delete().

Si vous pouviez énumérer les clés ou lire la valeur de taille, les résultats dépendraient du fait que le nettoyage des déchets vienne de s’exécuter — rendant le comportement de votre programme effectivement non déterministe. L’absence d’itérabilité permet à l’API de rester prévisible quel que soit le moment du nettoyage des déchets.

Un modèle pratique : un état d’instance véritablement privé

Au moment où les champs de classe privés natifs (#field) n’étaient pas encore largement pris en charge, WeakMap était la technique standard pour permettre aux instances de classe d’avoir un état interne véritablement privé :

const privateData = new WeakMap();

class BankAccount {
  constructor(initialBalance) {
    privateData.set(this, { balance: initialBalance });
  }

  deposit(amount) {
    const data = privateData.get(this);
    data.balance += amount;
  }

  getBalance() {
    return privateData.get(this).balance;
  }
}

const account = new BankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance);       // undefined (completely inaccessible)

Puisque c’est l’instance elle-même (this) qui sert de clé, une fois que l’instance n’est plus référencée nulle part, ses données privées sont automatiquement supprimées en même temps qu’elle.

3. Proxy et Reflect : le métaprogrammation dont vous pouvez utiliser aujourd’hui

Proxy enrobe un objet et vous permet d’intercepter ses opérations les plus basiques — lire une propriété, en assigner une, appeler une fonction ou supprimer une clé.

Si vous avez déjà mis à jour un état réactif dans Vue 3 ou observé une bibliothèque réactive moderne suivre automatiquement les changements, vous interagissiez en réalité avec un Proxy en arrière-plan.

const targetUser = { name: 'Alex', age: 28 };

const handler = {
  get(target, prop, receiver) {
    console.log(`Reading property "${prop}"`);
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    if (prop === 'age' && typeof value !== 'number') {
      throw new TypeError('Age must be a valid number.');
    }
    console.log(`Setting "${prop}" to ${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

const monitoredUser = new Proxy(targetUser, handler);

monitoredUser.age = 29; // Logs: Setting "age" to 29
console.log(monitoredUser.name); // Logs: Reading property "name" -> "Alex"
// monitoredUser.age = 'thirty'; // Throws TypeError

Vous avez peut-être remarqué l’utilisation de Reflect.get() et Reflect.set() à l’intérieur de ce gestionnaire Proxy. Reflect est un objet global intégré qui regroupe des méthodes reproduisant les opérations que peut intercepter un Proxy. Chaque fonctionnalité que vous pouvez définir dans un gestionnaire Proxy dispose d’une méthode correspondante dans Reflect qui accepte les mêmes arguments. Au lieu d’écrire manuellement des instructions comme target[prop] = value dans une fonctionnalité set — ce qui peut poser des problèmes subtils lorsqu’il s’agit de prototypes ou de propriétés d’accès — vous appelez la méthode Reflect appropriée, ce qui permet d’exécuter le comportement par défaut du moteur de manière sécurisée et de recevoir en retour une valeur booléenne indiquant si l’opération a réellement réussi.

4. FinalizationRegistry et WeakRef : observer le collecte des déchets

Pendant longtemps, il n’existait pas de moyen en JavaScript pour déterminer quand un objet avait réellement été récupéré par le moteur. ES2021 a comblé cette lacune en introduisant deux API de bas niveau liées entre elles, conçues pour une programmation de type système avancé : WeakRef et FinalizationRegistry.

WeakRef conserve une référence à un objet sans empêcher le collecteur de déchets de le récupérer, tout en vous permettant de récupérer l’objet tant qu’il est encore actif :

let heavyAsset = { buffer: new ArrayBuffer(1024 * 1024 * 16) }; // 16MB buffer
const assetRef = new WeakRef(heavyAsset);

// Dereference to access
const asset = assetRef.deref();
if (asset) {
  // Asset still exists in memory
  console.log("Using cached asset");
} else {
  // Asset was collected by GC; reload it
  console.log("Asset was garbage collected");
}

FinalizationRegistry complète cela en vous permettant de déclarer une fonction de rappel qui s’exécute une fois que l’objet a été collecté. Elle est généralement utilisée pour nettoyer les ressources externes liées à cet objet ou pour enregistrer des informations diagnostiques :

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`Cleanup hook: Resource "${heldValue}" was garbage collected.`);
});

(() => {
  const temporaryWorker = { id: 'worker_404' };
  registry.register(temporaryWorker, temporaryWorker.id);
  // temporaryWorker leaves scope here
})();

Faites preuve de prudence avec ces deux API. Le moment du ramassage des déchets varie selon les navigateurs et les moteurs JavaScript, et il est impossible de le prédire ou de l’imposer. Vous ne devez jamais relier la logique essentielle de votre application — le stockage de l’état, la confirmation d’une transaction, tout ce dont votre application dépend — à un callback de finalisation, car il n’y a aucune garantie quant au moment, voire à la possibilité, pour ce dernier d’être exécuté rapidement.

5. Fonctions générateurs : pause et reprise sur demande

Les fonctions JavaScript ordinaires suivent une règle d’exécution jusqu’à completion : une fois appelées, elles continuent de s’exécuter jusqu’à ce qu’elles atteignent un return ou lancent une exception, sans interruption intermédiaire.

Les fonctions générateurs, définies avec la syntaxe function*, enfreignent cette règle. Elles peuvent suspendre leur exécution et la reprendre ultérieurement, en utilisant le mot-clé yield pour marquer les points de pause.

function* idGenerator() {
  let id = 1;
  while (true) {
    yield `UID_${id++}`;
  }
}

const gen = idGenerator();
console.log(gen.next().value); // UID_1
console.log(gen.next().value); // UID_2
console.log(gen.next().value); // UID_3

Examinez de près la boucle while (true) à l’intérieur de ce générateur. Dans une fonction normale, une boucle infinie de ce type verrouillerait immédiatement le thread. Cependant, à l’intérieur d’un générateur, l’exécution s’arrête dès qu’elle atteint yield, rendant le contrôle à celui qui l’a appelé — rien d’autre ne se produit jusqu’à ce que .next() soit invoqué à nouveau.

Cela rend les générateurs particulièrement adaptés au traitement en flux de grandes quantités de données sans devoir les stocker entièrement en mémoire. Au lieu de lire un demi-million de lignes d’un fichier dans un seul grand tableau, un générateur peut produire chaque ligne pertinente une par une, au fur et à mesure des besoins :

function* processLargeLogFile(lines) {
  for (const line of lines) {
    if (line.includes('ERROR')) {
      yield line.trim();
    }
  }
}

// Memory remains flat because lines are yielded one by one as consumed
for (const errorLine of processLargeLogFile(rawLines)) {
  sendToAlertService(errorLine);
}

Aucune de ces fonctionnalités ne vous oblige à réviser immédiatement une base de code existante. Leur véritable avantage réside dans la capacité de savoir quand les utiliser plutôt qu’une approche conventionnelle qui entraînerait sinon un code fragile ou une surcharge mémoire inutile.

Lors de la prochaine fois où vous ajouterez des métadonnées à des nœuds DOM qui apparaissent et disparaissent dynamiquement, optez pour un WeakMap. Lors de la conception d’une architecture de plugins où du code externe doit définir ses propres clés non conflictuelles, utilisez un Symbol. Et lorsque vous avez besoin d’une couche de données réactive qui suit automatiquement les changements, construisez-la autour d’un Proxy.

Maîtriser ces outils est ce qui distingue l’écriture de code d’application de la conception de logiciels conçus pour évoluer.

Lectures complémentaires

  • Découvrir les prix cachés par ingénierie inverse des appels API en fond — Apprenez à utiliser les outils DevTools du navigateur pour trouver et reproduire les requêtes XHR, REST et GraphQL qui chargent les prix sans avoir besoin de parser l’HTML.