ES6 en détails est une série d’articles décrivant les nouvelles fonctionnalités ajoutées au langage de programmation JavaScript avec la sixième édition du standard ECMAScript (ES6 en abrégé).

Voilà ce qu’on va s’amuser à construire aujourd’hui :

var obj = new Proxy({}, {
  get: function (target, key, receiver) {
    console.log(`accès à ${key} !`);
    return Reflect.get(target, key, receiver);
  },
  set: function (target, key, value, receiver) {
    console.log(`modification de ${key} !`);
    return Reflect.set(target, key, value, receiver);
  }
});

Pour un premier exemple, c’est un peu ardu. Je vais expliquer les différentes parties au fur et à mesure. Pour le moment, regardons l’objet que nous venons de créer :

> obj.count = 1;
    accès à count !
> ++obj.count;
    accès à count !
    modification de count !
    2

Qu’est-ce qui se passe ? Nous sommes en train d’intercepter l’accès aux propriétés pour cet objet. Nous surchargeons l’opérateur ..

Comment ça marche

La virtualisation est un des meilleurs tours de passe-passe en informatique. C’est une technique générique qui permet de faire des choses extraordinaires. Voici comment elle fonctionne :

  1. Prenez une photopower-plant.jpgCrédit photo : Martin Nikolaj Bech
  2. Dessinez une forme quelque part sur cette imagepower-plant-with-outline.png
  3. Maintenant, remplacez tout ce qu’il y a à l’intérieur (ou à l’extérieur) de cette forme avec quelque chose d’inattendu. Une seule règle à respecter : la règle de la compatibilité ascendante. Le remplacement effectué doit ressembler suffisamment à ce qu’il y avait avant pour qu’un observateur de l’autre coté de la ligne ne puisse pas remarquer que quelque chose a changé.wind-farm.pngCrédit photo: Beverley Goodwin.

Ce tour de passe-passe est également présenté dans des films comme The Truman Show et Matrix où une personne est à l’intérieur de cette forme et où le reste du monde a été remplacé par une illusion de normalité.

Pour respecter cette règle de la compatibilité ascendante, il faut que le contenu utilisé pour le remplacement soit soigneusement conçu. Cependant, le plus important dans ce tour de force est de dessiner la bonne forme.

Par « forme », j’entends ici la frontière d’une API. Une interface. Les interfaces définissent comment deux morceaux de code peuvent interagir entre eux et ce que chacun attend de l’autre. Si une interface est déjà conçue dans le système, la forme est déjà dessinée. Vous savez que vous pouvez remplacer un des deux côtés sans que l’autre soit impacté.

C’est quand il n’y a pas d’interface existante que vous devrez faire preuve de créativité. Certains des projets logiciels les plus géniaux ont vu le jour grâce à la création d’une frontière d’API qui n’existait pas auparavant. Créer cette interface de toute pièce constitue une prouesse d’ingénierie.

La mémoire virtuelle, la virtualisation des composants matériels, Docker, Valgrind, rr sont tous, sous certains aspects, des projets qui ont impliqué la création de nouvelles interfaces, parfois inattendues, au sein de systèmes existants. Dans certains cas, cela a pris des années, a nécessité de nouvelles fonctionnalités des systèmes d’exploitation voire de nouveaux composants matériels pour que la nouvelle frontière fonctionne correctement.

Les meilleures techniques de virtualisation apportent également une nouvelle compréhension de ce qui est virtualisé. Avant d’écrire une API pour interfacer quelque chose, il faut le comprendre. Une fois que vous l’avez compris, vous pouvez réaliser des prouesses.

ES6 introduit le support de la virtualisation pour le concept primordial de JavaScript : l’objet.

Qu’est-ce qu’un objet ?

Sérieusement, réfléchissez-y pendant quelques minutes avant de poursuivre. Continuez cet article lorsque vous avez construit votre définition de ce qu’est un objet. thinker.jpgCrédit photo : Joe deSousa.

Cette question est trop difficile ! Je n’ai jamais entendu de définition complètement satisfaisante.

Est-ce bien surprenant ? La définition de concepts fondamentaux est toujours un exercice périlleux — prenez certaines des premières définitions des Éléments d’Euclide par exemple. La spécification du langage ECMAScript n’a donc pas à rougir quand elle définit un objet comme « un membre du type Object » (ce qui nous aide beaucoup).

Plus loin, la spécification ajoute « Un objet est une collection de propriétés ». Pas mal. Si vous voulez une définition, celle-ci pourra faire l’affaire pour le moment. Nous y reviendrons plus tard.

Plus haut, j’ai dit que pour écrire une API afin d’interfacer quelque chose, il faut le comprendre. D’une certaine façon, je vous ai promis que nous suivrons ce chemin, que nous comprendrons mieux ce que sont les objets et que nous ferons des choses formidables.

Prenons donc le chemin emprunté par le comité de standardisation ECMAScript et voyons ce qu’il faudrait pour définir une API, une interface, pour les objets JavaScript. De quels types de méthodes avons-nous besoin ? Que peuvent faire les objets ?

D’une certaine façon, cela dépend de l’objet. Les objets Element du DOM peuvent réaliser certaines choses, les objets AudioNode peuvent faire d’autres choses… Toutefois, il y a quelques capacités partagées par tous les objets :

  • Les objets ont des propriétés. Vous pouvez accéder à ces propriétés, les initialiser et les modifier, les supprimer et ainsi de suite.
  • Les objets ont des prototypes. C’est grâce à eux que l’héritage fonctionne en JavaScript.
  • Certains objets sont des fonctions ou des constructeurs. Vous pouvez les appeler.

Tout ce que les programmes JavaScript font avec les objets (ou presque) est fait en utilisant les propriétés, les prototypes et les fonctions. Même le comportement spécial des objets Element ou AudioNode provient des méthodes appelées qui sont des propriétés fonctionnelles héritées.

C’est pour cette raison que lorsque le comité de standardisation a défini un ensemble de 14 méthodes internes, l’interface commune à tous les objets, celles-ci se concentraient sur ces trois aspects fondamentaux.

La liste complète est décrite dans les tableaux 5 et 6 du standard ES6. Ici, je n’en décrirai que quelques-unes. Les doubles crochets, [[ ]], un peu étranges indiquent qu’il s’agit de méthodes internes, inaccessibles via du code JavaScript ordinaire. Ces méthodes ne peuvent pas être appelées, supprimées ou surchargées.

  • obj.[[Get]](key, receiver) – Obtenir la valeur d’une propriété.

    Appelée lorsque le code JS exécute : obj.prop ou obj[key].

    obj est l’objet dans lequel nous cherchons à accéder à une propriété, receiver est l’objet sur lequel nous commençons à chercher cette propriété. Parfois, nous voulons rechercher dans plusieurs objets. obj peut être un objet sur la chaîne de prototypes de receiver.

  • obj.[[Set]](key, value, receiver) – Affecter une propriété à un objet.

    Appelée lorsque le code JS exécute : obj.prop = valeur ou obj[key] = valeur.

    Lors d’une affectation comme obj.prop += 2, la méthode [[Get]] est appelée en premier et ensuite la méthode [[Set]]. De même pour ++ et --.

  • obj.[[HasProperty]](key) – Teste l’existence d’une propriété.

    Appelée lorsque le code JS exécute : key in obj.

  • obj.[[Enumerate]]() – Liste les propriétés énumérables de l’objet.

    Appelée lorsque le code JS exécute : for (key in obj) …

    Ceci retourne un objet itérateur, et c’est ainsi qu’une boucle for-in obtient les noms des propriétés d’un objet.

  • obj.[[GetPrototypeOf]]() – Retourne le prototype de l’objet.

    Appelée lorsque le code JS exécute : obj.__proto__ ou Object.getPrototypeOf(obj).

  • functionObj.[[Call]](thisValue, arguments) – Appelle une fonction.

    Optionnelle : tous les objets ne sont pas des fonctions.

    Appelé lorsque le code JS exécute : fonctionObj() ou x.methode().

  • constructorObj.[[Construct]](arguments, newTarget) – Invoque un constructeur.

    Appelée lorsque le code JS exécute : new Date(2890, 6 2), par exemple.

    Optionelle : tous les objets n’ont pas de constructeur.

    L’argument newTarget joue un rôle pour les sous-classes. Nous l’aborderons dans un prochain billet.

Vous pouvez peut-être deviner certaines des sept autres méthodes.

Avec le standard ES6, à chaque fois que c’est possible, le moindre petit morceau de syntaxe ou de fonction intégrée qui manipule des objets est spécifié selon l’une des 14 méthodes internes. ES6 marque une limite claire autour des mécanismes d’un objet.. Les proxies vous permettent de remplacer ces mécanismqes standard avec du code JavaScript arbitraire.

Dans un moment, lorsque nous commencerons à parler de surcharger ces méthodes internes, souvenez-vous, nous parlerons de surcharger le comportement de la syntaxe comme obj.prop, des fonctions natives comme Object.keys(), etc.

Proxy

ES6 définit un nouveau constructeur global : Proxy. Il prend deux arguments, un objet cible (target) et un objet gestionnaire (handler). Un exemple très simple pourrait être :

var target = {}, handler = {};
var proxy = new Proxy(target, handler);

Pour le moment, laissons l’objet gestionnaire de côté et concentrons-nous sur les liens entre le proxy et la cible.

Le comportement du proxy peut être expliqué en une phrase : toutes les méthodes internes du proxy sont retransmises à la cible. Par exemple, si quelque chose appelle proxy.[[Enumerate]](), cela renverra juste target.[[Enumerate]]().

Essayons. Utilisons une instruction qui entraîne l’appel de proxy.[[Set]]()

proxy.couleur = "rose";

OK, que s’est-il passé ? proxy.[[Set]]() devrait avoir appelé target.[[Set]]() et cela devrait avoir créé un nouvelle propriété sur target. Est-ce bien le cas ?

> target.couleur
    "rose"

Ça a bien fonctionné. Il en va de même pour les autres méthodes internes. Le proxy, dans la plupart des cas, se comportera exactement comme la cible.

Il y a certaines limites à cette illusion. Vous aurez notamment proxy!==target. Un proxy pourra également recaler certaines opérations à la suite des vérifications de types alors que celles-ci auraient fonctionné pour la cible. Par exemple, si la cible d’un proxy est un Element du DOM, le proxy ne sera pas vraiment un Element. Une opération comme document.body.appendChild(proxy) échouera avec une exception TypeError.

Les gestionnaires de proxies

Revenons vers l’objet gestionnaire (handler). C’est cet objet qui rend les proxies utiles.

Les méthodes de l’objet gestionnaire peuvent surcharger n’importe quelle méthode interne du proxy.

Par exemple, si vous voulez intercepter toutes les tentatives de modification/affectation de propriétés pour un objet, il vous suffit de définir une méthode handler.set() :

 
var target = {};
var handler = {
  set: function (target, key, value, receiver) {
    throw new Error("Merci de ne pas modifier de propriétés sur cet objet.");
  }
};
var proxy = new Proxy(target, handler);
 
> proxy.nom = "angelina";
    Error: Merci de ne pas modifier de propriétés sur cet objet.

La liste complète des méthodes pour les gestionnaires est documentée sur la page MDN de Proxy. Il y a 14 méthodes, chacune de ces méthodes faisant écho aux 14 méthodes internes définies dans ES6.

Toutes les méthodes pour les gestionnaires sont optionnelles. Si une méthode interne n’est pas interceptée par le gestionnaire, elle est simplement transmise à la cible comme nous l’avons vu auparavant.

Exemple : objets auto-généres

Nous en savons désormais assez sur les proxies pour les utiliser à des fins étranges et réaliser quelque chose qui est impossible sans les proxies.

Voici notre premier exercice : créer une fonction Arbre() qui peut faire ceci :

> var arbre = Arbre();
> arbre
    { }
> arbre.branche1.branche2.brindille = "vert";
> arbre
    { branche1: { branche2: { brindille: "vert" } } }
> arbre.branche1.branche3.brindille = "jaune";
    { branche1: { branche2: { brindille: "vert" },
                 branche3: { brindille: "jaune" }}}

Observez ici comment tous les objets intermédiaires branche1, branche2 et branche3 sont créés automatiquement, presque de façon magique, quand ils sont nécessaires. Plutôt pratique n’est-ce pas ? Comment cela pourrait-il fonctionner ?

Jusqu’à maintenant, il n’existait aucun moyen pour que cela puisse fonctionner. Cependant, avec les proxies, il suffit de quelques lignes de code. Il suffit de raccorder arbre.[[Get]](). Si vous voulez un défi, vous pouvez essayer d’implémenter ceci avant de continuer votre lecture. maple-tap.jpgPas la meilleure façon de se raccorder sur un arbre en JS. Crédit photo : Chiot’s Run.

Voici ma solution :

function Arbre() {
  return new Proxy({}, handler);
}
 
var handler = {
  get: function (target, key, receiver) {
    if (!(key in target)) {
      target[key] = Arbre();  // créer un sous arbre automatiquement
    }
    return Reflect.get(target, key, receiver);
  }
};

Notez l’appel à Reflect.get() à la fin de l’exemple. Il s’agit d’un besoin extrêmement commun, lorsqu’on utilise les méthodes d’un gestionnaire de proxy, que de vouloir dire « maintenant, je souhaite simplement appliquer le comportement par défaut à l’objet cible ». Pour cela, ES6 définit un nouvel objet Reflect qui possède 14 méthodes que l’on peut utiliser à cet effet.

Exemple : une vue en lecture seule

Je pense que j’ai pu donner une fausse impression en indiquant que les proxies seraient simples à utiliser. Prenons un exemple supplémentaire pour voir si c’est bien le cas.

Cette fois, le devoir est plus complexe : nous devons implémenter une fonction readOnlyView(object), qui prend n’importe quel objet et qui renvoie un proxy qui se comporte exactement comme cet objet sauf qu’il est impossible de le modifier. Elle se comporterait de la façon suivante :

> var newMath = readOnlyView(Math);
> newMath.min(54, 40);
    40
> newMath.max = Math.min;
    Error: impossible de modifier, vue en lecture seule
> delete newMath.sin;
    Error: impossible de modifier, vue en lecture seule

Comment implémenter cette fonction ? La première étape est d’intercepter toutes les méthodes internes qui pourraient modifier l’objet cible si nous les laissions passer. Il y en a cinq.

function NOPE() {
  throw new Error("impossible de modifier, vue en lecture seule");
}
 
var handler = {
  // On surcharge les cinq méthodes qui peuvent modifier.
  set: NOPE,
  defineProperty: NOPE,
  deleteProperty: NOPE,
  preventExtensions: NOPE,
  setPrototypeOf: NOPE
};
 
function readOnlyView(target) {
  return new Proxy(target, handler);
}

Y a-t-il des failles à ce système ?

Le plus problème est que la méthode [[Get]], ainsi que d’autres, peuvent toujours renvoyer des objets modifiables. Par exemple si un objet x est une vue en lecture seule, x.prop pourrait être modifié. C’est une faille digne de ce nom.

Pour corriger cela, nous devons ajouter une méthode handler.get() :

var handler = {
  ...
 
  // On enveloppe les autres résultats dans des vues en lecture seule.
  get: function (target, key, receiver) {
    // On applique le comportement par défaut.
    var result = Reflect.get(target, key, receiver);
 
    // On s'assure de ne pas renvoyer d'objet modifiable !
    if (Object(result) === result) {
      // result est un object.
      return readOnlyView(result);
    }
    // result est une valeur primitive, déjà non modifiable.
    return result;
  },
 
  ...
};

Ce n’est pas suffisant non plus. Il faudra un code similaire pour les autres méthodes, dont getPrototypeOf et getOwnPropertyDescriptor.

D’autres problèmes se posent ensuite. Lorsqu’un accesseur (getter) ou une méthode est appelé via ce type de proxy, la valeur passée à l’accesseur ou à la méthode sera généralement le proxy lui-même. Or, comme nous l’avons vu auparavant, de nombreux accesseurs et méthodes vérifient le type, ce qui empêchera le proxy de passer. Dans ces cas-là, il serait plus pratique de substituer le proxy par l’objet cible. Pouvez-vous trouver comment faire ?

La leçon à tirer de ces exemples : c’est simple de créer un proxy mais difficile de créer un proxy dont le comportement est intuitif.

Informations diverses et variées

  • À quoi les proxies sont-ils vraiment utiles ?

    Il sont certainement utiles lorsqu’on souhaite observer ou enregistrer les accès à un objet. Ils seront pratiques pour le débogage. Les frameworks de test pourraient les utiliser pour simuler les vrais objets.

    Les proxies sont utiles si vous avez besoin d’un comportement qui n’est pas à la portée d’un objet ordinaire : peupler des propriétés de façon automatique est un exemple.

    J’ai horreur de dire ça mais l’une des meilleure façon de voir ce qui se passe dans du code qui utilise des proxies… c’est d’envelopper le gestionnaire de proxy dans un autre proxy qui affiche des informations dans la console à chaque fois qu’une méthode du gestionnaire est appelée.

    Les proxies peuvent être utilisés pour restreindre l’accès à un objet comme on l’a vu avec readOnlyView. C’est un cas d’utilisation assez rare pour des applications mais Firefox utilise les proxies en interne afin d’implémenter la sécurité des frontières entre les différents domaines. Les proxies sont un élément clé de notre modèle de sécurité.

  • Proxies ♥ WeakMaps. Dans notre exemple sur readOnlyView, nous avons créé un nouveau proxy à chaque fois que nous accédions à un objet. Il serait possible d’économiser beaucoup de mémoire en mettant les différents proxies créés en cache dans une WeakMap. De cette façon, peu importe le nombre de fois qu’un objet est passé à readOnlyView, seul un proxy sera créé pour celui-ci.

    Vu sous cet angle, les proxies sont l’une des raisons d’utiliser WeakMap.

  • Les proxies révocables. ES6 définit également une autre fonction : Proxy.revocable(target.handler) qui crée un proxy de la même façon que new Proxy(target, handler), sauf que ce proxy peut être révoqué plus tard (Proxy.revocable renvoie un objet avec une propriété .proxy et une méthode .revoke). Une fois qu’un proxy est révoqué, il ne fonctionne plus, toutes ses méthodes internes lèveront des exceptions.
  • Les invariants d’objets. Dans certaines situations, ES6 requiert des résultats renvoyés par les méthodes du gestionnaire de proxy qui soient cohérents avec l’état de l’objet cible. Cette condition existe afin d’appliquer les règles qui concernent l’immuabilité parmi les différents objets, y compris les proxies. Par exemple, un proxy ne peut pas prétendre être inextensible si l’objet cible n’est pas réellement inextensible.

    Les règles exactes sont trop complexes pour être vues ici, mais si vous obtenez un message d’erreur qui ressemble à “proxy can’t report a non-existent property as non-configurable” (“le proxy ne peut pas signaler qu’une propriété inexistante n’est pas configurable”), ce sera pour cette raison. Le remède le plus probable est de modifier ce que le proxy prétend sur lui-même. Une autre possibilité est de modifier l’objet cible au vol afin que celui-ci reflète l’état du proxy.

Alors finalement, qu’est-ce qu’un objet ?

Je pense que nous en étions resté à « Un objet est une collection de propriétés ».

Je ne suis pas entièrement satisfait de cette définition, même en considérant comme acquis qu’on les intègre aux prototypes et aux appels. Je pense que le mot « collection » est exagéré vu comment un proxy ressemble à une collection. Ses méthodes de manipulation pourraient faire n’importe quoi. Elles pourraient retourner un résultat aléatoire.

En déterminant ce qu’un objet peut faire, en standardisant ces méthodes et en ajoutant la virtualisation en fonction prioritaire que tout le monde peut utiliser, le standard ECMAScript a ouvert le champ des possibles.

Les objets peuvent désormais être presque n’importe quoi.

Peut-être que la réponse la plus honnête à la question « Qu’est-ce qu’un objet ? » est maintenant de reprendre les 12 méthodes internes comme définition. Un objet est quelque chose dans un programme JS qui a une opération [[Get]], un opération [[Set]] et ainsi de suite.

Est-ce que nous comprenons mieux les objets avec tout ça ? Je n’en suis pas si sûr ! Avons-nous fait des choses étonnantes ? Certainement. Nous avons fait des choses qui auraient été auparavant impossibles en JS.

Puis-je utiliser les proxies dès maintenant ?

Non ! Seul Firefox supporte les proxies et il n’existe pas de prothèse (polyfill) correspondante. Vous êtes donc libre d’expérimenter avec eux. Vous pouvez créer un projet qui crée une galerie des glaces avec des milliers d’exemplaires pour chaque objet pour rendre le tout inextricable et indébogable, il n’y a aucun risque que ce code puisse passer en production… pour le moment.

Les proxies furent d’abord implémentés en 2010 par Andreas Gal dont le code a été revu par Blake Kaplan. Le comité de standardisation a ensuite totalement revu la conception de cette fonctionnalité. C’est Eddy Bruel qui a implémenté cette nouvelle spécification en 2012.

J’ai implémenté Reflect et ce code a été revu par Jeff Walden. Il sera dans Firefox Nightly à partir de ce week-end (NdT : le week-end du 18-19 juillet 2015). Il ne manque que Reflect.enumerate() qui n’est pas encore implémenté.

La prochaine fois, nous aborderons la fonctionnalité la plus controversée d’ES6, et qui mieux que la personne qui les a implémentées dans Firefox pour vous en parler ? (Re)joignez-nous la semaine prochaine avec Eric Faust, ingénieur Mozilla pour présenter les classes ES6 en détails.