Les objets Proxy en JavaScript
Comme on l’a vu dans un article précédent, JavaScript est un langage qui offre de nombreuses opportunités de méta-programmation. Dans ce nouvel article, je vous propose d’explorer les opportunités que nous offrent les objets Proxy
.
C’est quoi un Proxy ?
Pour le dire simplement, un Proxy
est un type d’objet JavaScript qui va enrober un autre objet et intercepter tous les appels qu’on va vouloir faire à l’objet original. Par “appels”, on parle de toutes les opérations qu’on peut faire sur un objet : lire et assigner une valeur à une propriété, créer, modifier ou supprimer une propriété, exécuter l’objet comme une fonction, instancier l’objet, etc.
Les objets Proxy
sont souvent utilisés en conjonction avec l’API de réflexion de JavaScript Reflect
, qui permet de réaliser toutes les opérations que le Proxy peut écouter.
Un exemple valant mieux qu’un long discours, créons une fonction qui va enrober un objet avec un Proxy
qui loggera toutes les opérations faites sur cet objet :
function logObjectAccess (obj) {
return new Proxy(obj, {
// Accès au propriétés de l'objet -----------
get(...args) {
console.log(`Get property ${String(args[1])} on:`, args[0])
return Reflect.get(...args)
},
set(...args) {
console.log(`Set property ${String(args[1])} on:`, args[0])
console.info(`value:`, args[2])
return Reflect.set(...args)
},
has(...args) {
console.log(`Check if ${String(args[1])} is a property on:`, args[0])
return Reflect.has(...args)
},
ownKeys(...args) {
console.log(`Get the object own properties of:`, args[0])
return Reflect.ownKeys(...args)
},
getOwnPropertyDescriptor(...args) {
console.log(`Ask description of property ${String(args[1])} of:`, args[0])
return Reflect.getOwnPropertyDescriptor(...args)
},
defineProperty(...args) {
console.log(`Define a new property ${String(args[1])} on:`, args[0])
console.info(`property description :`, args[2])
return Reflect.defineProperty(...args)
},
deleteProperty(...args) {
console.log(`Delete the property ${String(args[1])} on:`, args[0])
return Reflect.deleteProperty(...args)
},
// Appel de fonction ------------------------
apply(...args) {
console.log(`Call the object as function`, args[0])
console.info('arguments:', args[2])
console.info('this:', args[1])
return Reflect.apply(...args)
},
construct(...args) {
console.log(`Create a new instance of object`, args[0])
console.info('arguments:', args[1])
return Reflect.construct(...args)
},
// Accès à la configuration de l'objet ------
isExtensible(...args) {
console.log(`Ask if the object is extensible`, args[0])
return Reflect.isExtensible(...args)
},
preventExtensions(...args) {
console.log(`Define that the object should not be extensible`, args[0])
return Reflect.isExtensible(...args)
},
getPrototypeOf(...args) {
console.log(`Access the prototype of the object`, args[0])
return Reflect.getPrototypeOf(...args)
},
setPrototypeOf(...args) {
console.log(`Define a new prototype for the object`, args[0])
console.info('new prototype: ', args[1])
return Reflect.setPrototypeOf(...args)
}
})
}
Voila ! Comme on le voit dans cet exemple, le constructeur de l’objet Proxy
prend deux paramètres :
- D’une part l’objet qu’on va envelopper (on appel ça un RealSubject),
- D’autre part un objet de gestion des appels.
Chacune des propriétés de cet objet de gestion est un piège (en anglais : trap) qui va être chargé d’intercepter les opérations et de définir comment on va répondre à chacune.
Je vous ai mis ici les treize opérations qu’il est possible d’intercepter dans le langage, ce qui est à la fois peu et beaucoup. Si dans cet exemple, les appels d’opération sont transparents (grâce à l’utilisation systématique de Reflect
), il faut bien réaliser qu’on peut tout à fait décider de changer le comportement de ces opérations et donc, potentiellement de changer le comportement même du langage pour l’objet qu’on enveloppe !
Ok, alors tout ça, c’est bien joli, mais concrètement, ça sert à quoi ?
Différentes recettes de curry
Les Proxy permettant de changer les comportements des objets au niveau le plus fondamental du langage, ça ouvre d’intéressantes opportunités. Prenons un petit exemple simple : la curryfication de fonction.
En programmation fonctionnelle, la curryfication est un pattern qui consiste à prendre une fonction et à la décomposer en autant de fonctions intermédiaires qu’elle accepte d’arguments. Voilà à quoi cela peut ressembler :
function sum(a, b, c) {
return a + b + c
}
const add = curry(sum)
console.log(
sum(1, 2, 3) === add(1)(2)(3)
)
Un kata classique de programmation fonctionnelle consiste à écrire cette fameuse fonction curry
. Par exemple :
function curry(fn) {
const args = []
return function record(...args) {
args.push(...args)
return (
args.length >= fn.length ? fn(...args) : record
)
}
}
Si vous voulez vous amuser un peu, essayez d’écrire cette même fonction curry
pour des fonctions avec une arité infinie. 😉
On peut faire la même chose avec un Proxy
:
class ProxyHandler {
#args
constructor(args = []) {
this.#args = args
}
apply(fn, thisArg, args) {
args = [...this.#args, ...args]
return (
args.length === fn.length
? fn.apply(thisArg, args)
: new Proxy(fn, new ProxyHandler(args))
)
}
}
function curry(fn) {
return new Proxy(fn, new ProxyHandler())
}
Alors oui, pour un exemple aussi trivial, le fait de devoir écrire une classe ProxyHandler
pour maintenir l’état de complétion de nos arguments est clairement plus verbeux et moins élégant que la jolie fermeture lexicale (closure) que nous avons écrite précédemment. Oui… MAIS !
Le fait d’avoir un Proxy
nous ouvre les portes de tous les autres pièges, ce qui va nous donner l’occasion de manger du curry beaucoup plus épicé 😈. Par exemple, un curry plus verbeux qui permettrait ce genre de fantaisie :
console.log(
add.all(1, 2, 3), // 6
add(3).to(4).and.add.an.extra(3), // 10
add.the.number(10).to.twice.the.number(5), // 10 + 5 + 5 = 20
add.the.number(10).to.the.number(5).twice, // 10 + 10 + 5 = 25
)
Pour faire ça, nous avons besoin de créer une fonction pour le piège get
dans notre ProxyHandler
. Commençons par régler le cas assez simple des deux premiers exemples :
get(fn, prop, proxy) {
if (Reflect.has(fn, prop)) {
return Reflect.get(fn, prop)
}
return proxy
}
Avec ceci, à chaque fois qu’on va vouloir accéder à une propriété de notre fonction, on va :
- soit retourner le Proxy lui même si la propriété est inconnue (et donc chaîner à l’infini sur des propriétés qui n’existent pas !),
- soit retourner la valeur réelle de la propriété si elle existe sur notre fonction (merci l’API de réflexion qui permet de préserver l’API native normale de notre fonction, à savoir les propriétés
length
,name
,bind
,call
,apply
, etc.)
Évidemment, préserver ou non l’API native, pose des contraintes différentes sur la verbosité de votre curry donc c’est à vous de voir jusqu’où vous voulez casser JavaScript 😁. Sachant que si vous voulez avoir le meilleur des deux mondes (préserver l’API et continuer de pouvoir chaîner les appels de propriétés inconnues), rien ne vous empêche d’envelopper ce qui sort de Reflect.get
dans un Proxy
dédié ! Inception 😱 !
Bon… ok… restons calmes.
On ne va pas aller aussi loin dans cet article, ce serait peut-être un curry un peu trop épicé. On va déjà se contenter de gérer le cas de nos deux derniers exemples où nous utilisons les mots clés “to” et “twice” comme modificateurs du comportement de notre curry.
Le comportement de ces propriétés virtuelles, résumé sous forme de cas de tests, ça peut donner ça :
expect( add.twice.the.number(5).to.the.number(10) ).toBe(20)
expect( add.to.twice.the.number(5).the.number(10) ).toBe(20)
expect( add.twice.to.the.number(5).the.number(10) ).toBe(25)
expect( add.the.number(5).twice.to.the.number(10) ).toBe(20)
expect( add.the.number(5).to.twice.the.number(10) ).toBe(25)
expect( add.the.number(5).to.the.number(10).twice ).toBe(20)
Ce qui peut donner ce genre d’implémentation :
class ProxyHandler {
#props
#args
constructor(args = [], props = new Set()) {
this.#args = args
this.#props = props
}
get(fn, prop, proxy) {
if (Reflect.has(fn, prop)) {
return Reflect.get(fn, prop)
}
if (prop === 'to' || prop === 'twice') {
if (prop === 'twice' && this.#args.length >= fn.length - 1) {
return this.apply(fn, thisArg, [])
}
return new Proxy(fn, new ProxyHandler(
[...this.#args],
new Set([...this.#props, prop])
))
}
return proxy
}
apply(fn, thisArg, args) {
const action = [...this.#props].join('')
const lookup = action === 'totwice'
const lookback = action.startsWith('twice')
const looknext = action === 'twiceto'
if (this.#args.length === 0) {
if ((lookup || lookback) && !looknext) {
args.push(args.at(0))
}
} else {
args = [...this.#args, ...args]
if (lookup) { args.push(args.at(-1)) }
if (lookback) { args.push(args.at(0)) }
}
if (args.length >= fn.length) {
return fn.apply(thisArg, args)
}
return new Proxy(fn, new ProxyHandler(args, new Set(
(looknext && ['to', 'twice']) || []
)))
}
}
Alors évidemment, ce n’est pas très élégant, et quand vous commencez à jouer avec ce genre d’API en « langage naturel », il y a plein d’ambiguïtés qui apparaissent et qu’il faut penser à traiter. Par exemple, ici, il est très facile d’enregistrer plus de 3 paramètres ou bien de faire une tournure de phrase anglaise un peu bizarre qui rend l’usage de twice incompréhensible. Et puis, est-ce qu’on autorise les propriétés en camelCase en plus de la syntaxe à point ? Mais bon, ça, ce n’est plus un problème de Proxy
en tant que tel, mais plutôt un problème de design d’API.
Donc pour revenir à notre sujet, quels sont les usages un peu plus classiques des Proxy
?
Proxy or not Proxy, that is the question!
Comme son nom l’indique, un Proxy
va s’interposer entre les appels fait à un objet et les réponses qu’il retourne. En clair, on peut enrober tous les objets JavaScript, ce qui ouvre de sacrées perspectives.
Concrètement, ça va permettre de faire quatre choses différentes :
- Observer les appels faits à l’objet, idéal pour logger ou debugger sans altérer le comportement de l’objet.
- Modifier les données envoyées à l’objet, idéal pour faire de la validation ou de la transformation avant même que l’objet n’ait accès à ces données (c’est par exemple ce que j’ai fait dans mon précédent article sur la méta-programmation en JavaScript).
- Modifier les réponses renvoyées par l’objet, que ce soit en modifiant les données fournies par l’objet (formatage, transformation) ou en modifiant certains comportements natifs (par exemple faire des propriétés accessibles uniquement sous certaines conditions de configuration, ou rendre les modifications d’un objet asynchrone ou pour faire de la mise en cache, etc, etc, etc.)
- Étendre les capacités de l’objet sans l’altérer (en l’enrobant lui ou son prototype).
Si vous vous posez la question de la théorie derrière ces cas d’usages, renseignez-vous sur les Design Patterns : Proxy, Adapter et Decorator ou plus généralement la Programmation Orienté Aspect.
En JavaScript, il y a de multiples façons d’obtenir le même résultat et les quatre cas qu’on vient d’énoncer n’y font pas exception. Cependant, les objets Proxy
sont souvent la meilleure façon de le faire de manière totalement transparente et sans altérer l’objet original. En effet, l’usage d’un Proxy
est transparent en ce qu’il n’existe aucun moyen de savoir qu’un objet est enrobé avec un Proxy
! Seuls les outils de vos navigateurs peuvent vous le dire car ils ont un accès privilégié au code du moteur JavaScript mais pour nous, simples développeurs et développeuses, c’est impossible.
Pour finir, voici un dernier petit exemple concret qui utilise les Proxy
pour étendre les capacités d’un objet sans l’altérer : autoriser les index négatifs sur les Array
. Comme vous le savez, il n’est pas possible d’utiliser la syntaxe à crochet avec une valeur négative sur les tableaux de données JavaScript.
Assez récemment (ES2022) les tableaux ont gagné la méthode .at()
qui permet cet accès par valeur négative, pour autant ce n’est toujours pas possible avec la syntaxe à crochet et de toute façon, .at()
ne permet pas d’assigner une valeur… Changeons ça !
D’abord, illustrons le problème :
const arr = ['A', 'B', 'C']
// Tu te crois malin à vouloir remplacer "C" par "D"
arr[-1] = 'D'
console.log(arr.length) // 3 // Comme prévu
console.log(arr[-1]) // "D" // Et effectivement...
console.log(arr.at(-1)) // "C" // Ah ben non !
console.log(arr.join()) // "A,B,C" // Non mais carrément pas ! (╯°□°)╯︵ ┻━┻
Maintenant, réglons ça avec un Proxy :
const INDEX_HANDLER = (key) => (...args) => {
const array = args[0]
const index = Number(args[1])
if (index < 0) {
args[1] = array.length + index
}
return Reflect[key](...args)
}
const ProxyArray = {
get: INDEX_HANDLER('get'),
set: INDEX_HANDLER('set')
}
const arr = new Proxy(['A', 'B', 'C'], ProxyArray)
// Tu te crois malin à vouloir remplacer "C" par "D"
arr[-1] = 'D'
console.log(arr.length) // 3 // Mmh... 'kay
console.log(arr[-1]) // "D" // Admettons...
console.log(arr.at(-1)) // "D" // Ah mais oui !
console.log(arr.join()) // "A,B,D" // Victoire ! (^ω^)
La nature même de ce changement n’est possible que par l’utilisation des Proxy
.
Évidemment, l’implémentation que nous en avons faite ici est assez basique (on ne gère pas les propriétés qui sont des Symbol
, par exemple) et ne s’applique qu’à une instance donnée d’un tableau… Peut-on imaginer de généraliser ça à tous les tableaux ? Et qu’est-ce qu’on veut faire des indices négatifs qui dépassent la taille du tableau ? Etc, etc, etc… Là encore, malgré quelques limites les possibilités sont immenses, il ne reste plus qu’à imaginer ce qu’on veut.
Mais les limites des Proxy
, justement parlons-en.
Dura Lex, Sed Lex.
Les objets Proxy
sont un outil extrêmement puissant qui vous permettra de quasi littéralement « faire de la magie ». Cependant, bien que ce ne soit pas nécessairement très complexe à utiliser, leur fonctionnement reste assez subtil avec des effets de bord parfois un peu surprenants.
En particulier, il y a des limites à ce que peuvent faire les Proxy
car le langage JavaScript garantit certains invariants. Je ne vais pas être exhaustif ici, ni même rentrer dans le détail du fonctionnement interne des moteurs JS, mais il y a un certain nombre de cas qu’il est bon de connaître :
Les primitives
Si les Proxy
peuvent enrober tous les objets JavaScript, il ne peuvent néanmoins pas enrober les primitives. Ça n’est pas très surprenant pour les valeurs null
et undefined
(qui sont les seules valeurs en JavaScript à ne pas être des objets) autant on aurait pu l’imaginer pour les autres primitives (String
, Number
, Boolean
, Symbol
, BigInt
) qui sont techniquement des objets.
C’est une limite clairement assumée des Proxy
, les primitives ont beaucoup de comportements très spécifiques (à commencer par leur immuabilité) que les constructeurs de moteurs JS ne sont pas d’accord pour voir varier. La prédictibilité des comportements des primitives est au cœur du fonctionnement de JavaScript, les rendre instables pourrait créer des problèmes ingérables (en particulier pour la sécurité des utilisateurs de navigateur… personne ne veut que (false).valueOf()
puisse renvoyer true
!)
Plus généralement, s’il est possible d’enrober des objets, vous remarquerez que leurs prototypes sont protégés est qu’il est donc impossible de modifier globalement le comportement de ces objets via l’application, a posteriori, d’un Proxy
autour du prototype (mais on peut éventuellement le faire directement dans le constructeur des objets au moment de l’instanciation).
const desc = Object.getOwnPropertyDescriptor(Array, 'prototype')
console.log(desc.writable) // false
// on ne peut pas assigner un nouveau prototype aux Array
// Impossible d'y glisser un Proxy !
console.log(desc.configurable) // false
// on ne peut pas changer la nature de la propriété prototype des Array
// on ne peut donc pas changer sa configuration writable.
// Donc ça, ça ne marche pas :
Array.prototype = new Proxy(Array.prototype, ProxyArray)
// En mode strict: TypeError: "prototype" is read-only
// Par contre ça, ça marche :
globalThis.Array = new Proxy(Array, {
construct(...args) {
return new Proxy(Reflect.construct(...args), ProxyArray)
}
})
// Ce qui donne accès à :
const arr = new Array()
arr.push('A', 'B', 'C')
arr[-1] = 'D'
console.log(arr.join()) // A,B,D
// Cependant ça ne fonctionnera pas avec la syntaxe littéral
// car on ne modifie pas réellement le prototype de Array.
// On ne fait qu'enrober les nouvelles instances de Array
// créées avec l’opérateur new.
const arr = ['A', 'B', 'C']
arr[-1] = 'D'
console.log(arr.join()) // A,B,C (T_T)
Les objets scellés
JavaScript permet de contrôler l’extensibilité des objets. Aussi bien l’ajout et la suppression de propriétés que l’assignation de leurs valeurs. Pour ça, nous avons tout une série d’outils :
Object.preventExtensions()
Object.seal()
Object.freeze()
Object.defineProperty()
Object.defineProperties()
Dans le cas où un objet est protégé d’une manière ou d’une autre, le Proxy qui l’entoure n’est pas autorisé à mentir sur cet aspect de l’objet. En gros, si vous voulez faire croire qu’une opération sur un objet est possible alors qu’elle ne l’est pas, une exception sera levée. En résumé, un Proxy peut mentir sur ce qu’il est possible de faire, par contre il doit toujours dire la vérité sur ce qu’il n’est pas possible de faire.
const open = { iExist: true }
const closed = Object.freeze({ iExist: true })
const isOpen = new Proxy(open, { has: () => false })
const isClosed = new Proxy(closed, { has: () => false })
console.log('iExist' in isOpen) // false parce que ça pourrait être le cas
console.log('iExist' in isClosed) // TypeError ça n'est pas possible de dire non
Les fonctions et classes
Dernier point qu’il faut mentionner, la question des pièges apply
et contruct
. Ces deux pièges n’ont de sens que pour les objets qui sont invocables ou constructibles et ne s’appliqueront pas aux autres. En clair, on ne peut pas créer des fonctions ou des classes virtuelles à partir d’un objet quelconque.
Pour rappel :
- Les fonctions déclarées avec le mot clé
function
sont à la fois invocables et constructibles. - Les fonctions fléchées sont invocables mais ne sont pas constructibles
- Les classes sont constructibles mais ne sont pas invocables.
Conclusion
Cet article est bien long et on va s’arrêter là pour le moment. Il y aurait encore beaucoup à dire sur le sujet. Entre autres, je ne vous ai pas parlé des Proxy
révocables qui permettent de désolidariser à la demande un Proxy
de l’objet qu’il enveloppe. Cependant pour une introduction, vous avez déjà largement de quoi faire.
Si ce sujet vous intéresse et que vous voulez creuser un peu la question, voici quelques articles que je recommande chaudement. Pour ceux et celles qui veulent plus de petits exemples concrets d’usage des Proxy
, je vous invite à lire cet article de Thomas Barrasso (en anglais) : A practical guide to JavaScript Proxy ; Pour creuser un peu plus la dimension théorico-pratique des Proxy, je vous invite à lire cet article assez pointu de Tom Van Cutsem (en anglais) : Isolating application sub-components with membranes ; Enfin pour rentrer dans les détails d’implémentation des Proxy, je vous invite à lire cet article exhaustif de Axel Rauschmayer (en anglais) : Meta programming with ECMAScript 6 proxies
Les proxies, c’est fun et c’est riche, ils vous offrent une souplesse incomparable pour rendre JavaScript plus sûr et plus efficient pour vos besoins spécifiques. N’hésitez pas à réfléchir à ce que vous pourriez en faire et à expérimenter ; ce sera une corde de plus à votre arc.
Et comme toujours, explorez les langages que vous utilisez, comprenez leur fonctionnement, leurs forces et leurs faiblesses pour, toujours, devenir de meilleurs développeurs et développeuses.
Formation : Initiation à Javascript