Lecture 6 minutes
Il y a certaines choses quand on développe ou qu'on debug du JavaScript qu'on aurait aimé savoir pour éviter de tomber dans des pièges fourbes tels que les problèmes classiques de "var" pour les portées de variables. Cela devient d'autant plus complexe à appréhender lorsque l'on a une architecture plus élaborée avec de l'héritage, et donc l'utilisation de méthodes via prototype. Mon intention est de mettre en lumière certaines pratiques que j'ai pu voir et qui doivent être soit bannies, soit utilisées à bon escient lorsqu'on souhaite manipuler un attribut de type objet dans une instance de classe.
this.someObjet.someEntry !=
$this->someObject['someEntry']
Illustration mnémotechnique d'une syntaxe JS et PHP, mais pas toujours vraie selon le cas !
Vous ne pensez pas être concerné vu que vous utilisez le dernier framework tendance ? Détrompez vous, le piège est vite arrivé quand on ne sait pas ce que font certaines de leurs fonctionnalités !
Mise en situation
Mieux qu'une introduction, commençons par voir le résultat d'un code simple d'implémentation d'attribut de type objet dans l'objectif d'une utilisation au travers d'une instance. Dans le cas présent, nos objets "state" et "stateProto" illustrent différents états d'un quelconque connecteur :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
var Connector = function() { // Attribut de type objet implémenté au niveau de l'instance this.state = { open: false, init: false } // Fonction init implémentée au niveau de l'instance this.init = function() { this.stateProto.init = true; this.state.init = true; }; }; Connector.prototype = { // Attribut de type objet implémenté au niveau du prototype stateProto: { open: false, init: false }, // Fonction open implémentée au niveau du prototype open: function() { this.stateProto.open = true; this.state.open = true; } } // Initialisation de 2 instances de "Connector" var connector1 = new Connector(); var connector2 = new Connector(); connector1.open(); console.log(connector1.state); // {open: true, init: false} console.log(connector1.stateProto); // {open: true, init: false} console.log(connector2.state); // {open: false, init: false} console.log(connector2.stateProto); // {open: true, init: false} // Le statut "open" du connecteur 2 a été altéré ! connector2.init(); console.log(connector1.state); // {open: true, init: false} console.log(connector1.stateProto); // {open: true, init: true} // Le statut "init" du connecteur 1 a été altéré ! console.log(connector2.state); // {open: false, init: true} console.log(connector2.stateProto); // {open: true, init: true} |
Ce qui est mis en avant dans ce bout de code c'est que, quelle que soit la méthode appelée ou l'instance initiatrice, si une propriété d'un objet faisant partie de la définition du prototype est modifiée alors toutes les instances voient leurs valeurs modifiées. Pour d'autres langages, on pourrait dire que cela ressemble au comportement d'une variable statique mais voyons dans le cas du JavaScript ce qui se passe au moment de la compilation de cet exemple.
Le principe de la chaîne de prototypes est d'itérer sur les propriétés de l'objet jusqu'à trouver une valeur. Nous pouvons donc découper notre exemple par étapes :
- L'instance a-t-elle la propriété "state" ? (en code : connector1.hasOwnProperty('state'); // true )
- L'instance possède la propriété "state" donc c'est la valeur de l'instance qui est retournée
- L'instance a-t-elle la propriété "stateProto" ? (en code : connector1.hasOwnProperty('stateProto'); // false )
- L'instance ne possède pas la propriété "stateProto", le prototype de l'objet a-t-il la propriété "stateProto" ? (en code : connector1.__proto__.hasOwnProperty('stateProto'); // true )
- Le prototype possède la propriété "stateProto" donc c'est la valeur du prototype qui est retourné
Pour résumer, à chaque fois que nous allons utiliser la méthode init() pour tenter d'accéder ou de modifier une valeur au travers de la syntaxe this.stateProto.init , il faut avoir en tête que c'est l'équivalent de la syntaxe this.__proto__.stateProto.init qui est exécuté et qui fait référence à un seul et même objet, que this porte sur le contexte de "connector1" ou de "connector2" le compilateur ne fait aucune différence.
Sur la base de ce constat, allons voir ce même type d'implémentation utilisé au travers d'un framework et comprendre comment il est rapidement possible d'oublier cette règle fondamentale.
Déclaration d'un "composant" via un framework
La plupart des frameworks JavaScript mettent à disposition des objets très élaborés exploités au travers d'architectures complexes nous faisant oublier quelques fois les bases de l'héritage en programmation orientée prototype. Afin de se faire une représentation de ces mécanismes avec du code JavaScript "basique" et supporté au sens large, nous allons exclure volontairement l'utilisation Object.create() et autres outils apportés par ECMAScript 2015+.
Tout commence généralement avec une méthode "extend" permettant la fusion d'objets qui, pour une partie, est généralement implémenté avec la logique suivante :
1 2 3 4 5 6 7 |
function extend(target, source) { for(var propertyObject in source) { if(source.hasOwnProperty(propertyObject)) { target[propertyObject] = source[propertyObject]; } } } |
Pour créer nos "composants" (qui peuvent prendre en fonction des cas la signification d'un contrôleur, d'un modèle, d'un widget, etc.), nous créons des prototypes qui héritent d'autres prototypes à partir d'objets qui vont porter chacun leur logique propre. Notre "framework" va lui être une coquille pour factoriser le principe d'héritage que l'on souhaite avoir pour notre application et qui peut ressembler aux comportements d'autres frameworks, pour une partie encore, par cet exemple de code :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// ********* [PARTIE FRAMEWORK] ********* // "Component" est une représentation d'une classe délivrée par un framework // pour faire de l'héritage par prototype var Component = function() {}; Component.prototype = { /** * Exemple de méthode hérité */ format: function(value) { return 'Je me nomme ' + value + ' !'; } }; /** * On attache notre principe d'héritage à notre composant de base via une * méthode "extend" qui elle sera utilisée pour l'initialisation d'objets */ Component.extend = function(protoObject) { // On fait appel à la méthode "init" comme "constructor" du nouvel objet var ChildComponent = protoObject.init; // Le composant hérite des propriétés de base extend(ChildComponent.prototype, Component.prototype); // Le composant est initialisé avec les propriétés qui lui sont propres // ou pour modifier des propriétés de base extend(ChildComponent.prototype, protoObject); return ChildComponent; }; // ********* [/PARTIE FRAMEWORK] ********* |
Nous disposons d'un "framework" permettant de faire de l'héritage d'objets, nous pouvons maintenant l'utiliser pour notre logique métier :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// Déclaration d'un composant Person hérité de Component var Person = Component.extend({ // Propriété de type objet identityProto: { name: 'No name' }, /** * Implémentation d'une fonction init requise par le "framework" */ init: function(name) { this.identity = { name: 'No name' }; this.setName(name); }, /** * Exemple de méthode spécifique au composant */ setName: function(name) { this.identity.name = this.format(name); this.identityProto.name = this.format(name); } }); var johnny = new Person('Johnny'), mireille = new Person('Mireille'); console.log(johnny.identity); // {name: "Je me nomme Johnny !"} console.log(mireille.identity); // {name: "Je me nomme Mireille !"} console.log(johnny.identityProto); // {name: "Je me nomme Mireille !"} console.log(mireille.identityProto); // {name: "Je me nomme Mireille !"} console.log(Person.prototype.identityProto); // {name: "Je me nomme Mireille !"} |
Si on se concentre uniquement sur la déclaration de "Person", on pourrait croire que "identityProto" est un attribut de notre objet comme il pourrait l'être pour un autre langage à classes de la POO comme PHP, Java ou autre et être considéré comme une propriété de l'instance. Or comme on a pu le voir précédemment, le comportement n'est pas le même selon la manière d'initialiser notre propriété objet et donc d'accéder aux valeurs. Pour un œil non averti, l'erreur d'avoir utilisé "identityProto" au lieu de "identity" a été masquée par l'abstraction proposée par notre "framework", cela pourrait générer ainsi des heures passées à débugger d'autant plus lorsque ce même objet est réutilisé par plusieurs autres composants avec plusieurs instances de lui même !
POC : https://jsfiddle.net/yannberges/erpfeuo6/
Exemples
À titre d'exemples, nous avons là quelques cas d'implémentation d'un héritage où on devrait être mené à se poser la question de l'appartenance de la propriété de type objet au prototype ou à l'instance. Très loin de former une liste exhaustive, cela peut aider à se projeter face à ce type de déclaration de propriétés lorsqu'on utilise des outils et technologies à chaque fois différents :
- BackboneJS : https://jsfiddle.net/yannberges/1qLupsy4/
- Sidecar (solution Sugar 7 de SugarCRM avec BackboneJS) : https://bitbucket.org/snippets/synolia/oe7RGB
- Magento-ui (solution Magento Commerce 2 de Magento avec KnockoutJS) : https://bitbucket.org/snippets/synolia/reqBaR
- Oro-platform UIBundle (solution OroCommerce de Oro Inc. avec BackboneJS) : https://bitbucket.org/snippets/synolia/Bez7A6
Conclusion
L'important est que les intentions du développeur soient claires sur l'utilisation qui est faite d'une propriété de type objet dans ce type d'implémentation. Cela peut être pour définir des constantes qui apportent lisibilité et simplicité dans le code (
fileMode === File.modes.EDIT // 'edit' ), des objets aux ressources partagées pour de l'optimisation mémoire ou de l'interopérabilité de composants, etc ...
Des syntaxes ES6 comme "const" ou "class ... extends ..." peuvent aider pour améliorer la lisibilité de certaines de ces intentions mais elles n'excluent pas totalement la possibilité de tomber dans le type de confusion mis en évidence précédemment entre une valeur récupérée d'une propriété de l'instance et celle du prototype.