Comment les classes ES6 fonctionnent vraiment et comment construire les vôtres

La 6ème édition d'ECMAScript (ou ES6 en abrégé) a révolutionné le langage en ajoutant de nombreuses nouvelles fonctionnalités, notamment des classes et l'héritage basé sur les classes. La nouvelle syntaxe est facile à utiliser sans comprendre les détails et fait surtout ce que vous attendiez, mais si vous êtes comme moi, ce n’est pas très satisfaisant. Comment fonctionne la syntaxe apparemment magique sous le capot? Comment interagit-il avec les autres fonctionnalités de la langue? Est-il possible d'émuler des classes sans utiliser la syntaxe de classe? Ici, je vais répondre à ces questions avec un détail gratuit.

Mais d’abord, pour comprendre les classes, vous devez comprendre ce qui les précède et le modèle objet sous-jacent de Javascript.

Modèle d'objet

Le modèle d'objet Javascript est assez simple. Chaque objet est simplement un mappage de chaînes et de symboles en descripteurs de propriétés. Chaque descripteur de propriété contient à son tour une paire getter / setter pour les propriétés calculées ou une valeur de données pour les propriétés de données ordinaires.

Lorsque vous exécutez le code foo [bar], il convertit bar en chaîne s'il ne s'agit pas déjà d'une chaîne ou d'un symbole, puis recherche cette clé parmi les propriétés de foo et renvoie la valeur de la propriété correspondante (ou appelle sa fonction getter comme suit: en vigueur). Pour les clés de chaîne littérale qui sont des identificateurs valides, il existe la syntaxe abrégée foo.bar qui est équivalente à foo ["bar"]. Jusqu'ici, si simple.

Héritage prototypique

Javascript a ce qu'on appelle l'héritage prototypique, ce qui semble effrayant, mais est en réalité plus simple que l'héritage traditionnel basé sur les classes une fois que vous avez compris le principe. Chaque objet peut avoir un pointeur implicite vers un autre objet, appelé son prototype. Lorsque vous essayez d’accéder à une propriété d’un objet où aucune propriété n’existe avec cette clé, celle-ci recherche la clé sur l’objet prototype et renvoie la propriété du prototype pour cette clé, si elle existe. S'il n'existe pas sur le prototype, il vérifie de manière récursive le prototype du prototype, et ainsi de suite jusqu'à la fin de la chaîne, jusqu'à ce qu'une propriété soit trouvée ou qu'un objet sans prototype soit atteint.

Si vous avez déjà utilisé Python auparavant, le processus de recherche d'attribut est similaire. En Python, chaque attribut est d'abord recherché dans le dictionnaire d'instances. S'il n'y est pas présent, le moteur d'exécution vérifie ensuite le dictionnaire de classes, puis le dictionnaire de super classes, etc. jusqu'à la hiérarchie de l'héritage. En Javascript, le processus est similaire sauf qu'il n'y a pas de distinction entre les objets type et les objets d'instance - tout objet peut être le prototype de tout autre objet. Bien sûr, dans le monde réel, les gens utilisent rarement ce fait et organisent leur code dans des hiérarchies de type classe, car il est plus facile de le gérer, raison pour laquelle Javascript a d'abord ajouté la syntaxe de classe.

Fentes internes

Si tout un objet consiste en un mappage de clés en propriétés, où le prototype est-il stocké? La réponse est que, outre les propriétés, les objets ont également des méthodes internes et des slots internes qui sont utilisés pour implémenter une sémantique spéciale au niveau du langage. Le code Javascript ne permet aucun accès direct aux emplacements internes, mais dans certains cas, il est possible d'y accéder indirectement. Par exemple, les prototypes d'objet sont représentés par l'emplacement [[Prototype]] qui peut être lu et écrit à l'aide respectivement de Object.getPrototypeOf () et Object.setPrototypeOf (). Par convention, les emplacements internes et les méthodes sont écrits entre [[doubles crochets]] pour les distinguer des propriétés ordinaires.

Cours de style ancien

Dans les premières versions de Javascript, il était courant de simuler des classes à l'aide d'un code similaire à celui-ci.

D'où est-ce que sa vient? D'où vient le prototype? Que fait le neuf? En fait, même les premières versions de Javascript ne voulaient pas être trop non conventionnelles, elles incluaient donc une syntaxe qui vous permettait de coder des choses qui étaient un peu comme des classes.

En termes techniques, les fonctions en Javascript sont définies par les deux méthodes internes [[Call]] et [[Construct]]. Tout objet associé à une méthode [[Call]] est appelé une fonction et toute fonction possédant en outre une méthode [[Construct]] est appelée un constructeur¹. La méthode [[Call]] détermine ce qui se passe lorsque vous appelez un objet en tant que fonction, par exemple. foo (args), tandis que [[Construct]] détermine ce qui se passe lorsque vous l'appelez en tant que nouvelle expression, c'est-à-dire new foo ou new foo (args).

Pour les définitions de fonctions ordinaires², l'appel de [[Construct]] créera implicitement un nouvel objet dont [[Prototype]] est la propriété prototype de la fonction constructeur si cette propriété existe et a une valeur d'objet, ou Object.prototype sinon. L’objet nouvellement créé est lié à la valeur this dans l’environnement local de la fonction. Si la fonction retourne un objet, la nouvelle expression sera évaluée en fonction de cet objet. Sinon, la nouvelle expression est évaluée de manière implicite et crée cette valeur.

Quant à la propriété prototype, elle est implicitement créée chaque fois que vous définissez une fonction ordinaire. Chaque fonction nouvellement définie a une propriété nommée «prototype» définie avec un objet nouvellement créé comme valeur. Cet objet à son tour a une propriété constructeur qui renvoie à la fonction d'origine. Notez que cette propriété prototype n’est pas la même que l’emplacement [[Prototype]]. Dans l'exemple de code précédent, Foo n'est toujours qu'une fonction et son [[Prototype]] est donc l'objet prédéfini Function.prototype.

Voici un diagramme pour illustrer l'exemple de code précédent avec les relations [[Prototype]] en noir et les relations de propriété en vert et bleu.

diagramme de la hiérarchie du prototype pour l'exemple de code précédent

[1] Vous pouvez éventuellement avoir des objets avec une méthode [[Construct]] et aucune méthode [[Call]], mais la spécification ECMAScript ne définit aucun objet de ce type. Par conséquent, tous les constructeurs sont également des fonctions.

[2] Par définitions de fonctions ordinaires, j'entends des fonctions définies à l'aide du mot-clé fonction normale et rien d'autre, plutôt que => fonctions, fonctions génératrices, fonctions asynchrones, méthodes, etc. Bien entendu, avant ES6, il s'agissait du seul type de définition de la fonction.

Nouveaux cours de style

Avec ce fond de page, il est temps d’examiner la syntaxe de la classe ES6. L'exemple de code précédent est traduit directement dans la nouvelle syntaxe, comme suit:

Comme précédemment, chaque classe est composée d’une fonction constructeur et d’un objet prototype qui se réfèrent via les propriétés prototype et constructeur. Cependant, l'ordre de définition des deux est inversé. Avec une ancienne classe de style, vous définissez la fonction constructeur et l'objet prototype est créé pour vous. Avec une nouvelle classe de style, le corps de la définition de classe devient le contenu de l'objet prototype (à l'exception des méthodes statiques), et parmi elles, vous définissez un constructeur. Le résultat final est le même dans les deux cas.

Donc, si la syntaxe de classe ES6 n’est que du sucre pour les anciennes "classes", à quoi ça sert? Outre une apparence beaucoup plus agréable et l'ajout de contrôles de sécurité, la nouvelle syntaxe de classe comporte également des fonctionnalités impossibles avant l'ES6, notamment l'héritage basé sur les classes. Lorsque vous définissez une classe avec la nouvelle syntaxe, vous pouvez éventuellement fournir une super classe dont elle héritera, comme illustré ci-dessous:

Cet exemple en lui-même peut toujours être émulé sans syntaxe de classe, bien que le code requis soit beaucoup plus laid.

Avec l'héritage basé sur les classes, la règle est simple: chaque partie de la paire a pour prototype la partie correspondante de la superclasse. Le constructeur de la superclasse est donc le [[Prototype]] du constructeur de la sous-classe et l'objet prototype de la superclasse est le [[Prototype]] de l'objet prototype de la sous-classe. Voici un diagramme à illustrer (seuls les [[Prototypes]] sont affichés; les propriétés sont omises pour plus de clarté).

Il n'existe pas de moyen direct et pratique pour configurer ces relations [[Prototype]] sans utiliser la syntaxe de classe, mais vous pouvez les définir manuellement à l'aide de Object.setPrototypeOf (), introduit dans ES5.

Cependant, l'exemple ci-dessus évite notamment de faire quoi que ce soit dans les constructeurs. En particulier, cela évite super, une nouvelle syntaxe qui permet aux sous-classes d'accéder aux propriétés et au constructeur de la superclasse. Ceci est beaucoup plus compliqué et est en fait impossible à émuler complètement dans ES5, bien que vous puissiez l'émuler dans ES6 sans utiliser la syntaxe de classe ou super grâce à l'utilisation de Reflect.

Accès à la propriété de la superclasse

Il existe deux utilisations pour appeler un constructeur de super-classe ou accéder aux propriétés de la super-classe. Le second cas est plus simple, nous allons donc le traiter en premier.

Super fonctionne avec le fait que chaque fonction possède un emplacement interne appelé [[HomeObject]], qui contient l'objet dans lequel la fonction a été définie à l'origine si elle était définie à l'origine en tant que méthode. Pour une définition de classe, cet objet sera l’objet prototype de la classe, c’est-à-dire Foo.prototype. Lorsque vous accédez à une propriété via super.foo ou super ["foo"], cela équivaut à [[HomeObject]]. [[Prototype]]. Foo.

Avec cette compréhension de la façon dont fonctionne Super en coulisse, vous pouvez prédire comment il se comportera même dans des circonstances compliquées et inhabituelles. Par exemple, [[HomeObject]] d’une fonction est fixe au moment de la définition et ne changera pas, même si vous affectez ultérieurement la fonction à d’autres objets, comme indiqué ci-dessous.

Dans l'exemple ci-dessus, nous avons pris une fonction définie à l'origine dans D.prototype et l'avons copiée vers B.prototype. Etant donné que [[HomeObject]] pointe toujours sur D.prototype, le super accès recherche dans le [[Prototype]] de D.prototype, qui est C.prototype. Le résultat est que la copie de foo de C est appelée bien que C ne soit nulle part dans la chaîne de prototypes de b.

De même, le fait que [[HomeObject]]. [[Prototype]] soit recherché à chaque évaluation de la super-expression signifie que les modifications apportées au [[Prototype]] seront renvoyées, comme indiqué ci-dessous.

En remarque, super n'est pas limité aux définitions de classe. Il peut également être utilisé à partir de toute fonction définie dans un littéral d'objet à l'aide de la nouvelle syntaxe de méthode abrégée, auquel cas [[HomeObject]] sera le littéral d'objet englobant. Bien sûr, le [[prototype]] d’un littéral d’objet sera toujours Object.prototype, ce qui n’est donc pas très utile, sauf si vous réaffectez manuellement le prototype comme indiqué ci-dessous.

Emulation de super propriétés

Il n'y a aucun moyen de définir manuellement [[HomeObject]] sur nos méthodes, mais nous pouvons l'imiter en enregistrant simplement la valeur et en effectuant la résolution manuellement, comme indiqué ci-dessous. Ce n’est pas aussi pratique que d’écrire en super, mais au moins ça marche.

Notez que nous devons utiliser .call (this) pour nous assurer que la super-méthode est appelée avec le droit de cette valeur. Si la méthode a une propriété qui occulte Function.prototype.call pour une raison quelconque, nous pourrions utiliser à la place Function.prototype.call.call (foo, this) ou Reflect.apply (foo, this), qui sont plus fiables mais plus prolixes.

Super en méthodes statiques

Vous pouvez également utiliser super à partir de méthodes statiques. Les méthodes statiques sont identiques aux méthodes classiques, à ceci près qu'elles sont définies en tant que propriétés sur la fonction constructeur au lieu de l'objet prototype.

super peut être imité dans les méthodes statiques de la même manière que les méthodes normales. La seule différence est que [[HomeObject]] est maintenant la fonction constructeur plutôt que l'objet prototype.

Super constructeurs

Lorsque la méthode [[Construct]] d'une fonction constructeur ordinaire est appelée, un nouvel objet est créé implicitement et lié à la valeur this à l'intérieur de la fonction. Cependant, les constructeurs de sous-classes suivent des règles différentes. Il n'y a pas de création automatique de cette valeur et toute tentative d'y accéder entraîne une erreur. Au lieu de cela, vous devez appeler le constructeur de la super-classe via super (args). Le résultat du constructeur de la superclasse est ensuite lié à la valeur locale de cette valeur, après quoi vous pouvez y accéder normalement dans le constructeur de la sous-classe.

Cela pose bien sûr des problèmes si vous souhaitez créer une ancienne classe de style capable d'interopérer correctement avec les nouvelles classes de style. Il n'y a aucun problème à sous-classer une ancienne classe de style avec une nouvelle classe de style, car le constructeur de la classe de base est simplement une fonction constructeur ordinaire dans les deux cas. Toutefois, sous-classer une nouvelle classe de style avec une ancienne classe de style ne fonctionnera pas correctement, car les constructeurs de style ancien sont toujours des constructeurs de base et ne possèdent pas le comportement spécial du constructeur de sous-classe.

Pour concrétiser le défi, supposons que nous ayons une nouvelle classe de style Base dont la définition est inconnue et qui ne puisse pas être modifiée. Nous souhaitons la sous-classer sans utiliser la syntaxe de la classe, tout en restant compatibles avec le code de Base qui attend une vraie sous-classe.

Tout d'abord, nous supposerons que Base n'utilise pas de mandataires, ni de propriétés calculées non déterministes, ni quoi que ce soit d'autre, car notre solution accédera probablement aux propriétés de Base un nombre de fois différent ou dans un ordre différent de celui d'une sous-classe réelle. et nous ne pouvons rien y faire.

Ensuite, la question est de savoir comment configurer la chaîne d’appel du constructeur. Comme avec les super propriétés ordinaires, nous pouvons facilement obtenir le constructeur de la superclasse à l'aide de Object.getPrototypeOf (homeObject) .constructor. Mais comment l'invoquer? Heureusement, nous pouvons utiliser Reflect.construct () pour appeler manuellement la méthode interne [[Construct]] de n’importe quelle fonction constructeur.

Il n’ya aucun moyen d’émuler le comportement spécial de cette liaison, mais nous pouvons simplement l’ignorer et utiliser une variable locale pour stocker la valeur «réelle» de cette valeur, nommée $ this dans l’exemple ci-dessous.

Notez le retour $ this; ligne ci-dessus. Rappelez-vous que si une fonction constructeur renvoie un objet, celui-ci sera utilisé comme valeur de la nouvelle expression au lieu de la valeur implicitement créée.

Alors, mission accomplie? Pas assez. La valeur obj de l'exemple ci-dessus n'est en réalité pas une instance de Child, c'est-à-dire que Child.prototype ne figure pas dans sa chaîne de prototypes. En effet, le constructeur de Base ne connaissait rien de Child et renvoyait donc un objet qui n’était qu’une simple instance de Base (son [[Prototype]] est Base.prototype).

Alors, comment ce problème est-il résolu pour de vraies classes? [[Construct]] et, par extension, Reflect.construct, prennent en réalité trois paramètres. Le troisième paramètre, newTarget, est une référence au constructeur initialement appelé dans la nouvelle expression, et donc au constructeur de la classe la plus basse (la plus dérivée) de la hiérarchie d'héritage. Une fois que le flux de contrôle atteint le constructeur de la classe de base, cet objet implicitement créé aura newTarget comme [[Prototype]].

Par conséquent, nous pouvons faire en sorte que Base construise une instance de Child en appelant le constructeur via Reflect.construct (constructeur, args, Child). Cependant, cela n’est pas encore tout à fait correct, car cela casserait à chaque fois que quelqu'un sous-classe Child Au lieu de coder en dur la classe enfant, nous devons laisser newTarget inchangé. Heureusement, les constructeurs peuvent y accéder à l'aide de la syntaxe spéciale new.target. Cela conduit à la solution finale ci-dessous:

Touches finales

Cela couvre toutes les fonctionnalités principales des classes, mais il existe quelques autres différences mineures, principalement des contrôles de sécurité ajoutés à la nouvelle syntaxe de classe. Par exemple, la propriété prototype automatiquement ajoutée aux définitions de fonction est accessible en écriture, mais la propriété prototype des constructeurs de classe n'est pas accessible en écriture. Nous pouvons aussi facilement rendre notre écriture impossible en appelant Object.defineProperty (). Alternativement, vous pouvez simplement appeler Object.freeze () si vous voulez que tout soit immuable.

Une autre nouvelle protection est que les constructeurs de classe émettent une erreur TypeError si vous essayez de les [[Appeler]] au lieu de les construire avec new. Notre constructeur ci-dessus génère également une erreur TypeError, mais uniquement indirectement, car new.target est indéfini lorsque la fonction est [[Call]] ed et Reflect.construct () lève une erreur TypeError si vous passez explicitement indéfini comme dernier argument. Dans la mesure où l'erreur TypeError est une coïncidence, le message d'erreur résultant est plutôt déroutant. Il peut être utile d’ajouter une vérification explicite pour new.target qui génère une erreur avec un message d’erreur plus utile.

Quoi qu'il en soit, j'espère que vous avez apprécié ce post et appris autant que j'ai fait dans le processus de recherche. Les techniques ci-dessus sont rarement utiles dans le code du monde réel, mais il est toujours important de comprendre comment les choses fonctionnent sous le capot si vous avez un cas d'utilisation inhabituel qui nécessite de rechercher la magie noire, ou plutôt si vous êtes coincé dans l'obligation de le faire. déboguez la magie noire de quelqu'un d'autre.

P.S. Si, comme moi, vous êtes agacés par la bannière géante et inviolable de Medium au bas de l'écran vous invitant à vous inscrire, ou par la quête générale des sites Web pour rendre la lecture de leur contenu aussi difficile et gênante que possible, je vous recommande vivement de jeter un oeil à Kill Gluant. C'est un extrait de code Javascript simple que vous pouvez marquer, qui supprime tous les éléments «collants» de la page. Cela semble simple, mais naviguer avec Kill Sticky change la vie. Et comme il ne s'agit que d'un bookmarklet, vous n'avez pas à craindre de supprimer accidentellement des éléments de page importants pour de bon, comme vous le feriez avec un filtre uBlock. Au pire, vous pouvez toujours rafraîchir la page.