Le mot-clé this & les prototypes
Deuxième pilier : le contexte dynamique de this, la chaîne de prototypes, et les « classes » comme sucre syntaxique.
Si la portée (scope) et la fermeture (closure) forment le premier pilier de JavaScript, le second est constitué de deux mécanismes intimement liés et particulièrement mal compris : le mot-clé this et le prototype. Ce sont deux des racines les plus profondes du langage, celles qui font « tourner » la quasi-totalité du code orienté objet que vous écrirez.
Kyle Simpson insiste : ce chapitre creuse plus loin qu'on n'a l'habitude de penser un langage. L'objectif n'est pas de vous donner des recettes, mais de répondre aux « Pourquoi ? ». Pourquoi this ne désigne-t-il presque jamais ce que les développeurs croient ? Pourquoi un objet peut-il appeler une méthode qu'il ne possède pas ? Pourquoi class n'est-il, au fond, qu'un déguisement ? Démêlons tout cela.
this n'est ni la fonction, ni sa portée
Commençons par balayer les deux idées reçues les plus tenaces. La première : le this d'une fonction désignerait la fonction elle-même. La seconde, héritée d'autres langages : this pointerait vers l'instance à laquelle une méthode appartient. Les deux sont fausses.
Pour comprendre this, il faut le distinguer de la portée. Quand une fonction est définie, elle est rattachée à sa portée englobante par fermeture. La portée (scope) est l'ensemble des règles qui contrôlent la résolution des références aux variables ; elle est statique et fixée au moment et à l'endroit où vous écrivez la fonction. Mais une fonction possède une seconde caractéristique qui influence ce à quoi elle peut accéder : son contexte d'exécution (execution context), exposé via this.
À retenir
La portée d'une fonction est figée à la définition. Son contexte d'exécution, lui, est dynamique : il dépend entièrement de la manière dont la fonction est appelée, sans aucun égard pour l'endroit où elle a été définie ni même d'où elle est appelée.
On peut se représenter le contexte d'exécution comme un objet tangible dont les propriétés sont mises à disposition de la fonction pendant qu'elle s'exécute. this est la porte d'accès à cet objet. Et puisque cet objet change à chaque appel, this aussi.
function classroom(teacher) {
return function study() {
console.log(`${teacher} dit d'étudier ${this.topic}`);
};
}
var assignment = classroom("Kyle"); La fonction externe classroom(..) ne mentionne aucun this : c'est une fonction ordinaire, qui de surcroît ferme sur la variable teacher. Mais la fonction interne study() référence this : on dit qu'elle est consciente de this (this-aware). Sa valeur dépendra du contexte d'exécution au moment de l'appel.
La même fonction, trois appels, trois this
Le caractère dynamique de this se voit le mieux en appelant une seule et même fonction de trois façons différentes et en observant trois résultats distincts.
// 1. Appel « nu », sans contexte fourni.
assignment();
// Kyle dit d'étudier undefined -- Aïe :( Ici, assignment() est appelée comme une fonction normale, sans aucun contexte. Hors du mode strict (strict mode), une fonction consciente de this appelée sans contexte voit son this rabattu sur l'objet global (window dans le navigateur). Comme il n'existe pas de variable globale topic, this.topic vaut undefined. (En mode strict, this serait undefined et l'accès lèverait une erreur, ce qui est souvent préférable.)
// 2. Appel comme MÉTHODE d'un objet.
var homework = {
topic: "JS",
assignment: assignment,
};
homework.assignment();
// Kyle dit d'étudier JS On pose une copie de la référence de la fonction comme propriété de homework, puis on l'appelle via homework.assignment(). Le this est alors l'objet placé à gauche du point au moment de l'appel : homework. Donc this.topic vaut "JS". Notez bien : ce n'est pas l'objet où la fonction est définie qui compte, c'est l'objet à travers lequel on l'appelle.
// 3. Appel avec call(..) : this fixé explicitement.
var otherHomework = {
topic: "Math",
};
assignment.call(otherHomework);
// Kyle dit d'étudier Math Troisième manière : call(..) (et son jumeau apply(..)) prend un objet en premier argument et l'utilise comme this pour cet appel précis. Ici, this.topic résout "Math".
Une même fonction consciente du contexte, invoquée de trois façons, donne trois réponses différentes quant à l'objet que this référence. C'est précisément l'intérêt : un this dynamique permet de réutiliser une seule fonction avec les données de différents objets. Une fonction qui ferme sur une portée ne peut jamais référencer une autre portée ; une fonction consciente de this, elle, s'adapte à son contexte d'appel.
Note
Au-delà de ces trois cas, il existe deux autres façons de fixer this : bind(obj) crée une nouvelle fonction dont le this est figé d'avance sur obj ; et l'appel avec new crée un objet flambant neuf et le lie à this. Nous reviendrons sur new avec les prototypes.
La fonction fléchée : un this lexical
La fonction fléchée (=>) est l'exception qui confirme la règle. Elle n'a pas son propre this. Elle ne participe pas du tout au mécanisme dynamique décrit ci-dessus. À la place, elle hérite du this de la portée englobante — exactement comme elle accède à n'importe quelle variable extérieure par fermeture. C'est ce qu'on appelle un this lexical.
Simpson le souligne dans l'Appendix A : la fonction fléchée a un but bien précis, traiter this de manière lexicale, et ce n'est pas une raison pour l'employer partout. C'est l'outil adapté à ce besoin-là, pas un raccourci universel.
var calculateur = {
facteur: 3,
valeurs: [10, 20, 30],
multiplier() {
// La fléchée hérite du this de `multiplier`,
// c'est-à-dire `calculateur`.
return this.valeurs.map((v) => v * this.facteur);
},
};
calculateur.multiplier(); // [30, 60, 90] Comparez avec une fonction classique passée à map, dont le this aurait été rabattu sur l'objet global (ou undefined), rendant this.facteur inaccessible. La fléchée résout élégamment ce piège historique des callbacks — mais uniquement parce qu'elle abandonne le this dynamique.
Attention
Ne définissez jamais une méthode d'objet avec une fléchée en espérant que this désigne l'objet. La fléchée capture le this de l'endroit où elle est écrite, pas de l'objet sur lequel on l'appelle. { f: () => this.x } ne fera pas ce que vous croyez.
Le prototype : un lien entre deux objets
Là où this est une caractéristique de l'exécution d'une fonction, le prototype est une caractéristique de l'objet — et plus précisément de la résolution d'un accès à une propriété.
Voyez le prototype comme un lien caché entre deux objets. Ce lien est établi à la création d'un objet : le nouvel objet est rattaché à un autre objet qui existe déjà. Une suite d'objets ainsi reliés forme une chaîne de prototypes (prototype chain).
À quoi sert ce lien d'un objet B vers un objet A ? À ce que tout accès, sur B, à une propriété ou méthode que B ne possède pas, soit délégué à A. C'est la délégation (delegation) : deux objets (ou davantage) coopèrent pour accomplir une tâche.
var homework = {
topic: "JS",
};
// homework ne définit AUCUN toString...
homework.toString(); // "[object Object]" L'objet homework n'a qu'une propriété, topic. Pourtant homework.toString() fonctionne. Pourquoi ? Parce que le lien prototype par défaut de tout objet littéral pointe vers Object.prototype, qui porte des méthodes communes comme toString() et valueOf(). L'accès absent sur homework est délégué le long de la chaîne jusqu'à Object.prototype.toString().
Object.create et la chaîne de prototypes
Pour établir explicitement un lien prototype, on dispose de Object.create(..). Son premier argument est l'objet auquel relier le nouvel objet créé ; elle renvoie ce nouvel objet, déjà lié.
var homework = {
topic: "JS",
};
var otherHomework = Object.create(homework);
otherHomework.topic; // "JS" -- délégué à homework On obtient une chaîne à trois maillons : otherHomework → homework → Object.prototype. L'accès otherHomework.topic ne trouve rien sur otherHomework, remonte d'un cran et lit topic sur homework.
Un point capital : la délégation ne joue QUE pour la lecture d'une propriété. Une affectation s'applique toujours directement sur l'objet ciblé, sans égard pour le maillon où la propriété existe peut-être déjà.
homework.topic; // "JS"
otherHomework.topic; // "JS" (délégué)
otherHomework.topic = "Math"; // crée la propriété SUR otherHomework
otherHomework.topic; // "Math" -- réponse directe, non déléguée
homework.topic; // "JS" -- inchangé ! L'affectation crée une propriété topic directement sur otherHomework. Elle ne touche pas homework. Désormais otherHomework.topic lit la nouvelle propriété locale et n'a plus besoin de déléguer : on dit qu'elle masque (shadowing) la propriété de même nom plus haut dans la chaîne.
Astuce
Object.create(null) crée un objet sans aucun lien prototype : un objet vraiment isolé, sans toString() ni quoi que ce soit hérité. Pratique pour une « carte » de données pure, sans risque de collision avec les méthodes d'Object.prototype.
Pourquoi this doit être dynamique : la délégation à l'œuvre
On comprend maintenant la véritable raison d'être d'un this dynamique. Sans lui, la délégation par prototype serait inutile. Examinons une méthode partagée par plusieurs objets liés.
var homework = {
study() {
console.log(`Veuillez étudier ${this.topic}`);
},
};
var jsHomework = Object.create(homework);
jsHomework.topic = "JS";
jsHomework.study();
// Veuillez étudier JS
var mathHomework = Object.create(homework);
mathHomework.topic = "Math";
mathHomework.study();
// Veuillez étudier Math jsHomework et mathHomework sont tous deux liés au même objet homework, qui détient l'unique fonction study(). Chacun possède en revanche sa propre propriété topic.
Quand on appelle jsHomework.study(), l'accès à study est délégué à homework.study(). Mais — et c'est tout le sel — le this de cette exécution résout jsHomework, parce que c'est l'objet à travers lequel l'appel a été lancé. Donc this.topic vaut "JS". De même, mathHomework.study() délègue à la même fonction mais résout this à mathHomework, d'où "Math".
Si this était résolu statiquement à homework (l'objet où study() est défini, comme on s'y attendrait dans beaucoup de langages), tout l'intérêt s'effondrerait : les deux appels afficheraient la même chose. Le this dynamique de JS est le composant critique qui permet à la délégation par prototype — et donc à class — de fonctionner comme prévu.
Les « classes » sont du sucre syntaxique
Nous arrivons à la révélation de l'Appendix A. Le mot-clé class, arrivé avec ES6, n'introduit aucun nouveau mécanisme dans le langage. C'est du sucre syntaxique (syntactic sugar) posé par-dessus les prototypes. Comprendre ce qu'il fait réellement sous le capot dissipe bien des malentendus.
Avant class, on câblait ces liens prototype via le motif dit des « classes prototypales » (prototypal classes) — un prédécesseur honnêtement assez laid. Simpson note au passage qu'il reste, curieusement, fréquent en entretien d'embauche. Voici le même comportement de délégation, écrit à l'ancienne :
function Classroom() {
// ..
}
Classroom.prototype.welcome = function hello() {
console.log("Bienvenue, étudiants !");
};
var mathClass = new Classroom();
mathClass.welcome();
// Bienvenue, étudiants ! Décortiquons le mécanisme, car le nommage prête à confusion. Toute fonction classique (déclaration ou expression normale) référence par défaut un objet vide via une propriété nommée prototype (ce n'est le cas ni des fonctions fléchées ni des méthodes concises). Attention : ce Classroom.prototype n'est PAS le prototype de la fonction Classroom (l'objet auquel elle serait liée), mais l'objet auquel seront liés les objets créés en appelant la fonction avec new. On greffe une méthode welcome sur cet objet.
Ensuite, new Classroom() crée un nouvel objet (assigné à mathClass) et le lie par prototype à Classroom.prototype. Bien que mathClass ne possède pas de welcome, l'appel mathClass.welcome() délègue avec succès à Classroom.prototype.welcome(). C'est exactement la même délégation que tout à l'heure, simplement câblée autrement.
Ce motif est aujourd'hui fortement déconseillé au profit de class :
class Classroom {
constructor() {
// ..
}
welcome() {
console.log("Bienvenue, étudiants !");
}
}
var mathClass = new Classroom();
mathClass.welcome();
// Bienvenue, étudiants ! À retenir
Sous le capot, exactement le même câblage de prototype est mis en place que dans la version « classe prototypale ». La méthode welcome() vit sur Classroom.prototype ; new crée une instance liée à cet objet ; et l'appel délègue par la chaîne de prototypes. La syntaxe class colle simplement mieux au motif de conception orienté classe — mais le moteur, lui, ne connaît toujours que des objets et des liens prototype.
Autrement dit, quand vous écrivez class, vous ne quittez jamais le monde des prototypes. JavaScript n'a pas de « vraies » classes au sens des langages à classes : il a des objets qui délèguent à d'autres objets, et class est une façade élégante posée par-dessus. Garder cette vérité en tête vous évitera quantité de surprises.
À retenir
thisn'est ni la fonction ni sa portée. C'est le contexte d'exécution, dynamique, résolu à chaque appel selon la manière dont la fonction est appelée.- Les façons de fixer
this: appel nu (objet global ouundefineden strict), appel comme méthode (obj.f()→this = obj),call/apply(explicite),bind(figé d'avance),new(nouvel objet). - La fonction fléchée n'a pas son propre
this: elle hérite duthislexical englobant. Idéale pour les callbacks, à proscrire comme méthode d'objet. - Le prototype est un lien caché entre objets. Une lecture de propriété absente est déléguée le long de la chaîne de prototypes ; une affectation, elle, reste toujours locale (masquage).
- Le
thisdynamique est ce qui rend la délégation utile : une méthode partagée s'exécute avec lethisde l'objet appelant, pas de l'objet qui la porte. classest du sucre syntaxique au-dessus des prototypes :new,Classroom.prototypeet la délégation sont le même mécanisme, simplement mieux habillé.