Accueil / Articles / Comprendre Proxy en JavaScript : pièges, Reflect et patterns réactifs

Comprendre Proxy en JavaScript : pièges, Reflect et patterns réactifs

Apprenez comment les objets Proxy et Reflect de JavaScript interceptent l’accès aux propriétés afin de permettre la validation, les propriétés virtuelles et les frameworks réactifs.

1096 mots

Chaque fois que vous écrivez obj.property ou que vous lui assignez une valeur avec obj.property = value, vous faites appel à des opérations que JavaScript exécute en silence, sans moyen intégré de surveiller ou de modifier ce qui se passe en arrière-plan. L’objet Proxy élimine complètement cette hypothèse. Il vous permet d’entourer un objet cible et d’intercepter ces opérations fondamentales — lecture, écriture, suppression — avant qu’elles n’aient d’effet. Ce mécanisme d’interception constitue en réalité le véritable moteur derrière beaucoup de ce qui ressemble à de la « magie » dans les bibliothèques et outils JavaScript modernes.

Un enveloppeur capable d’intercepter n’importe quoi

Proxy se situe entre votre code et l’objet qu’il entoure, et un ensemble de fonctions de capture décide de ce qui se passe réellement pour chaque type d’opération effectuée sur cet objet.

const config = { retries: 3, timeout: 5000 };

const guardedConfig = new Proxy(config, {
  set(target, key, value) {
    if (key === "retries" && (typeof value !== "number" || value < 0)) {
      throw new TypeError("retries must be a non-negative number");
    }
    target[key] = value;
    return true;
  },
});

guardedConfig.retries = 5;   // works fine
guardedConfig.retries = -1;  // throws TypeError, caught before it ever reaches the object

Récrire guardedConfig.retries = 5 ne semble pas différent d’une simple affectation de propriété. C’est précisément l’objectif du design : l’interception reste invisible au niveau de l’appel. Vous pouvez ajouter des validations, du journalage ou d’autres effets secondaires par-dessus ce qui ressemble à un accès normal aux propriétés, sans avoir à modifier le code qui utilise réellement l’objet.

Le mécanisme derrière les frameworks réactifs

function reactive(target, onChange) {
  return new Proxy(target, {
    get(obj, key) {
      return obj[key];
    },
    set(obj, key, value) {
      const changed = obj[key] !== value;
      obj[key] = value;
      if (changed) onChange(key, value);
      return true;
    },
  });
}

const state = reactive({ count: 0 }, (key, value) => {
  console.log(`${key} changed to ${value}, re-rendering...`);
});

state.count = 1; // "count changed to 1, re-rendering..." — no explicit call needed

Écrire state.count = 1 ressemble à une affectation tout à fait normale, car sur le plan syntaxique c’en est bien une. Ce qui lui donne un sens, c’est le piège set, qui transforme cette ligne banale en un événement auquel le reste du système peut réagir. C’est là la véritable base sur laquelle reposent les frameworks réactifs. Il n’existe aucun type de données « observable » spécial que vous deviez utiliser partout. Il s’agit simplement d’objets ordinaires enveloppés dans un Proxy qui détecte silencieusement toute écriture effectuée sur eux.

Propriétés virtuelles : des valeurs qui n’existent que lorsque vous les demandez

Un piège get peut librement retourner quelque chose qui n’a jamais été réellement stocké dans l’objet cible, ce qui vous permet d’exposer des valeurs calculées ou dérivées comme s’il s’agissait de propriétés ordinaires.

const product = { name: "Desk Lamp", priceCents: 3499 };

const productView = new Proxy(product, {
  get(target, key) {
    if (key === "priceDollars") {
      return (target.priceCents / 100).toFixed(2);
    }
    return target[key];
  },
});

console.log(productView.priceDollars); // "34.99" — computed on read, not stored anywhere

Il n’y a aucun champ priceDollars quelque part dans l’objet product. Il est généré dynamiquement, chaque fois qu’il est consulté, à l’intérieur de la trappe get. Cela diffère significativement d’un getter que l’on écrirait avec get à l’intérieur d’une classe ou d’un objet littéral, car une trappe Proxy peut intercepter l’accès à des clés qui n’ont jamais été déclarées au préalable. C’est utile, par exemple, lorsque l’on souhaite envelopper une réponse API avec des champs calculés supplémentaires sans toucher au payload d’origine.

Pourquoi Reflect apparaît souvent avec Proxy

Il existe une subtilité qui surprend ceux qui écrivent pour la première fois une trappe nécessitant de recourir à un comportement par défaut. Le faire de manière naïve peut facilement entraîner des erreurs subtiles.

const handler = {
  get(target, key) {
    console.log(`reading ${key}`);
    return target[key]; // works, but loses some edge-case correctness
  },
};

Pour les objets simples et basiques, cette approche fonctionne généralement bien, mais la manière plus sûre et plus correcte de faire en sorte qu’une opération suive son comportement par défaut est d’utiliser Reflect. Il expose une fonction pour chaque cas particulier qui reproduit exactement l’opération, en préservant le bon lien this et en gérant correctement des situations plus complexes, comme les getters hérités.

const handler = {
  get(target, key, receiver) {
    console.log(`reading ${key}`);
    return Reflect.get(target, key, receiver);
  },
};

L’appel de Reflect.get(target, key, receiver) reproduit exactement ce que l’accès normal aux propriétés aurait fait, y compris les cas limites impliquant des prototypes et des getteurs dépendants de this, ainsi que les situations où target[key] peut produire silencieusement un résultat incorrect. Dans le code Proxy pratique, la convention est presque toujours d’appeler la méthode Reflect correspondante depuis l’intérieur de chaque trappe, sauf si l’on souhaite modifier intentionnellement le comportement par défaut, car c’est la version qui garantit une correspondance avec ce que l’accès normal aux propriétés ferait normalement.

Trappes qui restreignent plutôt qu’elles n’étendent

Les pièges ne servent pas seulement à ajouter du comportement, ils peuvent tout aussi facilement l’enlever. Un piège has contrôle ce que le opérateur in renvoie, tandis qu’un piège deleteProperty peut refuser complètement la suppression.

const secureRecord = new Proxy(
  { id: 1, ssn: "123-45-6789" },
  {
    get(target, key) {
      if (key === "ssn") throw new Error("Direct access to ssn is not allowed");
      return Reflect.get(target, key);
    },
    deleteProperty() {
      throw new Error("Deleting fields is not allowed on this record");
    },
  }
);

console.log(secureRecord.id); // 1
console.log(secureRecord.ssn); // throws
delete secureRecord.id; // throws

Ce type de protection diffère de manière importante d’un champ de classe privé : il peut être appliqué à n’importe quel objet, sans que cet objet ait nécessairement été défini en tant que classe au départ, et il permet de définir des règles précises par clé, que une simple déclaration #private globale ne peut pas exprimer.

Une idée, de nombreux pièges

Chaque piège répond en réalité à la même question : que se passe-t-il dès que le code tente d’effectuer cette opération quotidienne sur cet objet ? Lire une valeur, en écrire une, la supprimer, vérifier s’une clé existe – ce sont toutes des actions normalement invisibles et automatiques, et Proxy est ce qui transforme chacune d’elles en un moment que l’on peut observer ou rediriger. Les systèmes de state réactif, les enveloppes de validation, les couches de contrôle d’accès, les champs calculés virtuels : aucun de ces éléments n’est un truc isolé tiré de sources différentes. Ils sont tous basés sur le même mécanisme fondamental, simplement appliqué à un piège différent.

Lectures complémentaires