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.
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
- Construire un modèle mental pour React : Reconciliation, State et Hooks — Découvrez les raisons qui sous-tendent les concepts fondamentaux de React — reconciliation, composants, props, state et hooks — afin de développer une intuition plutôt que de mémoriser des API.
- Diagnostiquer les problèmes de performances de React au-delà du délai de réponse de l’API — Comprenez pourquoi des API rapides ne garantissent pas nécessairement une interface utilisateur rapide, et comment le rendu, la taille du bundle et l’organisation des fichiers influencent discrètement les véritables performances d’une application React.