JavaScript peut parfois se révéler déroutant, notamment pour les développeurs habitués à des langages fonctionnant avec des classes (Java ou C++ par exemple). JavaScript possède un typage dynamique et ne fournit pas d'implémentation de classe (le mot-clé class a été introduit avec ECMAScript 2015 mais ne fournit qu'un sucre syntaxique, JavaScript continue d'avoir un modèle d'héritage basé sur les prototypes).

Dès lors qu'on aborde l'héritage, JavaScript n'utilise qu'un seul concept : les objets. Chaque objet possède un lien, interne, vers un autre objet, appelé prototype. Cet objet prototype possède lui aussi un prototype et ainsi de suite, jusqu'à ce que l'on aboutisse à un prototype null. null, n'a par définition, aucun prototype et forme donc le dernier maillon de la chaîne des prototypes.

Souvent considéré comme l'un des points faibles de JavaScript, le modèle d'héritage des prototypes se révèle plus puissant que le modèle habituel basé sur les classes. On peut par exemple construire assez facilement un modèle classique à partir d'un modèle basé sur des prototypes.

L'héritage et la chaîne des prototypes

L'héritage de propriétés

Les objets JavaScript sont des conteneurs de propriétés (appelées propriétés propres de l'objet) et chaque objet possède un lien vers un objet prototype. Lorsqu'on souhaite accéder à une propriété d'un objet, on recherche d'abord parmi les propriétés propres de l'objet, puis parmi celles de son prototype, puis parmi celles du prototype du prototype et ainsi de suite jusqu'à ce qu'une propriété correspondante soit trouvée ou que l'on ait atteint la fin de la chaîne de prototypes.

Afin de suivre la notation standard ECMAScript, on utilisera unObjet.[[Prototype]] pour faire référence au prototype de unObjet. Cela est équivalent à la propriété __proto__ auparavant dépréciée et introduite formellement avec ECMAScript 2015, pour le support des anciennes versions, il est conseillé d'utiliser Object.getPrototypeOf() et Object.setPrototypeOf() pour accéder au [[Prototype]]. Attention à ne pas confondre cette propriété et la propriété prototype des constructeurs qui définit la valeur de [[Prototype]] pour toutes les instances créées via ce constructeur.

Voici un exemple qui décrit ce qui se passe lorsqu'on tente d'accéder à une propriété :

// On a l'objet o, qui a des propriétés propres a et b:
// {a: 1, b: 2}
var o = {a: 1, b: 2};
// o.[[Prototype]] a les propriétés b et c:
// {b: 3, c: 4}
var proto = {b: 3, c: 4};
Object.setPrototypeOf(o, proto);
// Enfin o.[[Prototype]].[[Prototype]] vaut null
// C'est donc la fin de la chaîne de prototype.
// Par définition, null n'a pas de [[Prototype]].
Object.setPrototypeOf(proto, null);
// La chaîne de prototypes ressemble donc à :
// {a:1, b:2} ---> {b:3, c:4} ---> null

console.log(o.a); // 1
// Est-ce que 'a' est une propriété propre de o ? Oui, elle vaut 1.

console.log(o.b); // 2
// Est-ce que 'b' est une propriété propre de o ? Oui, elle vaut 2.
// Le prototype possède aussi une propriété 'b' mais celle-ci n'est pas
// utilisée.
// On parle alors de « masque de propriété » (property shadowing)

console.log(o.c); // 4
// Est-ce que 'c' est une propriété propre de o ? Non, on vérifie le prototype
// Est-ce que 'c' est une propriété propre de o.[[Prototype]] ?
// Oui, elle vaut 4.

console.log(o.d); // undefined
// Est-ce que 'd' est une propriété propre de o ? Non, on vérifie le prototype
// Est-ce que 'd' est une propriété propre de o.[[Prototype]] ?
// Non on vérifie son prototype.
// o.[[Prototype]].[[Prototype]] vaut null, on arrête la recherche.
// La propriété n'a pas été trouvée, on renvoie undefined

Renseigner une propriété d'un objet créera une propriété pour l'objet. La seule exception à ce comportement se produit lorsqu'il y a une propriété héritée d'un getter ou setter (respectivement aussi appelés accesseur ou mutateur).

L'héritage de « méthodes »

JavaScript n'utilise pas les méthodes au sens des langages de classes. En JavaScript, toute fonction peut être rattachée à un objet en tant que propriété. Une fonction héritée agit comme n'importe quelle autre propriété, y compris pour le masquage décrit ci-avant (c'est ici une façon de surcharger une méthode).

Lorsqu'une fonction héritée est exécutée, la valeur de this pointe vers l'objet qui hérite et non pas vers le prototype qui dispose de la fonction parmi ses propriétés.

var o = {
  a: 2,
  m: function(){
    return this.a + 1;
  }
};

console.log(o.m()); // 3
// Quand on appelle o.m, 'this' fait référence à o

var p = Object.create(o);
// p est un objet héritant de o

p.a = 12; // crée une propriété 'a' pour p
console.log(p.m()); // 13
// lorsque p.m est appelé, 'this' fait référence à p.
// Lorsque p hérite de m grâce à o, 'this.a' signifie p.a, avec 'a' la propriété de p

Différentes façons de créer des objets et leurs chaînes de prototypes

Créer des objets grâce à des éléments syntaxiques

var o = {a: 1};

// L'objet o qui vient d'être créé hérite de Object.prototype.
// o ne possède pas de propriété 'hasOwnProperty'
// hasOwnProperty est une propriété de Object.prototype. Donc o hérite de hasOwnProperty grâce à Object.prototype
// Object.prototype a null comme prototype.
// Voici la chaîne de prototypes : 
// o ---> Object.prototype ---> null

var b = ["yo", "bien", "?"];

// Les tableaux (Array) héritent du prototype Array.prototype (possédant les méthodes indexOf, forEach, etc.).
// La chaîne de prototypes est la suivante :
// b ---> Array.prototype ---> Object.prototype ---> null

function f(){
  return 2;
}

// Les fonctions héritent du prototype Function.prototype (avec les méthodes call, bind, etc.):
// f ---> Function.prototype ---> Object.prototype ---> null

Avec un constructeur

Un constructeur JavaScript est « juste » une fonction appelée avec l'opérateur new.

function Graph() {
  this.sommets = [];
  this.aretes = [];
}

Graph.prototype = {
  ajouteSommet: function(v) {
    this.sommets.push(v);
  }
};

var g = new Graph();
// g est un objet avec les propriétés 'sommets' et 'aretes'.
// g.[[Prototype]] est la valeur de Graph.prototype quand new Graph() est exécuté.

Avec Object.create()

ECMAScript 5 a permis d'introduire une nouvelle méthode : Object.create(). Appeler cette méthode permet de créer un nouvel objet. Le prototype de cet objet est le premier argument de cette fonction :

var a = {a: 1}; 
// a ---> Object.prototype ---> null

var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (hérité)

var c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null

var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty); // undefined, en effet d n'hérite pas de Object.prototype

Avec le mot-clé class

ECMAScript 2015 a introduit un nouvel ensemble de mots-clés pour implémenter des classes. Bien que cela puisse paraître familier aux développeurs habitués à des langages de classes, cela ne transforme pas le modèle de JavaScript qui reste basé sur les prototypes. Les nouveaux mots-clé incluent class, constructor, static, extends, et super.

"use strict";

class Polygone {
  constructor(hauteur, largeur) {
    this.hauteur = hauteur;
    this.largeur = largeur;
  }
}

class Carre extends Polygone {
  constructor(longueurCote) {
    super(longueurCote, longueurCote);
  }
  get aire() {
    return this.hauteur * this.largeur;
  }
  set longueurCote(nouvelleLongueur) {
    this.hauteur = nouvelleLongueur;
    this.largeur = nouvelleLongueur;
  }
}

var carre = new Carre(2);

Performances

La recherche ascendante dans la chaîne des prototypes peut avoir un certain impact sur les chaînes de prototypes particulièrement longues. De plus, tenter d'accéder à des propriétés qui n'existent pas se traduira toujours par une analyse de toute la chaîne de prototypes.

Ainsi, lorsque l'on itère sur les propriétés d'un objet, chaque propriété énumérable de la chaîne de prototypes sera listée.

Afin de vérifier si un objet dispose d'une propriété qui lui est propre et qui n'est pas plus haut dans la chaîne, il est nécessaire d'utiliser la méthode hasOwnProperty, dont tous les objets héritent grâce à Object.prototype. Avec l'exemple précedent sur le graphe, on pourrait écrire 

console.log(g.hasOwnProperty('sommets'));
// true

console.log(g.hasOwnProperty('nooon'));
// false

console.log(g.hasOwnProperty('ajouteSommet'));
// false

console.log(g.__proto__.hasOwnProperty('ajouteSommet'));
// true

hasOwnProperty est le seul élément JavaScript qui est lié aux propriétés et qui ne parcourt pas la chaîne de prototypes.

Note : Il ne suffit pas de vérifier qu'une propriété vaut undefined. En effet, la propriété peut très bien exister et valoir, à cet instant, undefined.

Une méthode à proscrire : l'extension de prototypes natifs

On voit souvent, à mauvais escient, l'extension du prototype Object.prototype ou d'un autre prototype natif.

Cette technique casse l'encapsulation nécessaire au fonctionnement du langage objet. Bien qu'elle soit utilisée dans certains frameworks connus, comme Prototype.js, il n'y a pas de raisons valables pour augmenter les types natifs avec des fonctionnalités non-standard.

La seule bonne raison de faire ceci est d'améliorer le portage de nouvelles fonctionnalités dans un ancien environnement JavaScript comme par exemple reconstruire Array.forEach etc.

prototype et Object.getPrototypeOf()

JavaScript peut parfois être source de confusion pour les développeurs habitués à Java ou à C++ : en effet, tout est dynamique, se déroule lors de l'exécution et il n'y a aucun concept natif de classe (en dehors du sucre syntaxique vu ci-avant). Il n'y a aucune classe, que des instances.

La fonction A possède une propriété spéciale appelée prototype. Cette propriété spéciale fonctionne avec l'opérateur JavaScript new. Cela permettra de lier l'objet prototype avec la propriété interne [[Prototype]] de la nouvelle instance. Ainsi, quand on fait var a1 = new A(), après avoir créé l'objet en mémoire et avant de lancer la fonction A() avec this utilisant la valeur de l'objet, JavaScript définit a1.[[Prototype]] = A.prototype. Lorsque l'on accède aux propriétés d'un objet, JavaScript vérifie d'abord si elles existent directement sur l'objet, si ce n'est pas le cas, il parcourt son [[Prototype]]. Ainsi, toutes les propriétés définies dans prototype sont partagées par toutes les instances et il est possible de modifier prototype par la suite, ce qui mettra à jour les propriétés liées dynamiquement.

Avec cet exemple :

function A(a) {
  this.varA = a;
}

A.prototype = {
  faireQuelqueChose : function(){
    // ...
  }
};

function B(a, b) {
  A.call(this, a);
  this.varB = b;
}
B.prototype = Object.create(A.prototype, {
  varB : {
    value: null, 
    enumerable: true, 
    configurable: true, 
    writable: true 
  },
  faireQuelqueChose : { 
    value: function(){ // override
      A.prototype.faireQuelqueChose.apply(this, arguments);
      // ...
    },
    enumerable: true,
    configurable: true, 
    writable: true
  }
});
B.prototype.constructor = B;
var b = new B();

Si on fait var a1 = new A(); var a2 = new A(); puis a1.faireQuelqueChose, cela ferait référence à Object.getPrototypeOf(a1).faireQuelqueChose, ce qui correspond au A.prototype.faireQuelqueChose qu'on a défini (autrement dit Object.getPrototypeOf(a1).faireQuelqueChose == Object.getPrototypeOf(a2).faireQuelqueChose == A.prototype.faireQuelqueChose).

Cela signifique que prototype est utilisé pour les types et que Object.getPrototypeOf() est utilisé pour les instances.

[[Prototype]] est parcouru de façon récursive, c'est-à-dire que le moteur JavaScript cherchera d'abord a1.faireQuelqueChose, puis Object.getPrototypeOf(a1).faireQuelqueChose, Object.getPrototypeOf(Object.getPrototypeOf(a1)).faireQuelqueChose etc. jusqu'à ce que la méthode soit trouvée ou que Object.getPrototypeOf() renvoie null.

Ainsi, lorsque l'on appelle :

var o = new Toto();

JavaScript exécutera des instructions équivalentes à :

var o = new Object();
o.[[Prototype]] = Toto.prototype;
Toto.call(o);

Lorsque l'on utilisera l'instruction suivante :

o.unePropriete;

Le moteur JavaScript vérifiera que o possède la propriété unePropriete. Si ce n'est pas le cas, il vérifiera Object.getPrototypeOf(o).unePropriete et ainsi de suite avec Object.getPrototypeOf(Object.getPrototypeOf(o)).unePropriete et ainsi de suite.

Les types d'héritage prototypal

On peut distinguer trois types d'héritage utilisant les prototypes : l'héritage par délégation l'héritage par concaténation et l'héritage fonctionnel.

L'héritage par délégation

Un prototype de délégation sert de base à un autre objet. Lorsqu'un objet hérite d'un tel prototype, il possède une référence vers cet objet et lorsqu'on accède à une propriété sur l'objet, le moteur vérifie les propriétés propres avant de remonter la chaîne de prototypes pour trouver cette propriété.

La délégation permet d'économiser des ressources car un seul exemplaire de chaque méthode sera partagé par l'ensemble des instances.

Voici une façon de créer ce type d'héritage :

class Salutations {
  constructor(nom) {
    this.nom = nom || 'Jean Biche';
  }
  coucou() {
    return `Coucou mon nom est ${ this.nom }`;
  }
}

const george = new Salutations('George');
const msg = george.coucou();
console.log(msg); // Coucou mon nom est George

On peut tronquer la délégation en utilisant null comme prototype avec  Object.create(null).

Un inconvénient de la délégation est qu'elle ne permet pas de stocker efficacement des états. En effet, si l'état est stocké dans des objets ou des tableaux, modifier une des valeurs entraînera une modification indirecte de l'ensemble des objets qui en héritent.

Si on souhaite avoir un exemplaire d'état sûr pour chaque instance, il faudra en faire une copie pour chaque objet.

L'héritage par concaténation

L'héritage par concaténation consiste à copier les propriétés d'un objet sur un autre sans conserver ensuite de référence entre les deux objets. Cet héritage est permis grâce à la possibilité d'étendre des objets de façon dynamique en JavaScript.

Un tel clonage permet de stocker efficacement des états entre les objets. Cet héritage est généralement construit en utilisant Object.assign() et, avant ES6, pouvait être construit avec les méthodes similaires à .extend() pour Lodash, Underscore ou jQuery.

const proto = {
  coucou: function coucou() {
    return `Coucou, je m'appelle ${ this.name }`;
  }
};

const george = Object.assign({}, proto, {nom: 'George'});
const msg = george.coucou();
console.log(msg); // Coucou, je m'appelle George

L'héritage par concaténation est très puissant, mais il devient encore meilleur quand vous le combinez avec des closures.

L'héritage fonctionnel

L'héritage fonctionnel utilise une fonction de construction puis ajoute de nouvelles propriétés grâce à l'héritage par concaténation.

Les fonctions créées afin d'étendre des objets existants sont généralement appelées mixins fonctionnels. L'avantage de ces fonctions est que l'utilisation des fermetures (closures) permet de conserver un état privé avec des valeurs qu'on ne puisse pas manipuler sans passer par une API bien définie :

// import Events from 'eventemitter3';
const rawMixin = function () {
  const attrs = {};
  return Object.assign(this, {
    set (name, value) {
      attrs[name] = value;
      this.emit('change', {
        prop: name,
        value: value
      });
    },
    get (name) {
      return attrs[name];
    }
  }, Events.prototype);
};

const mixinModel = (target) => rawMixin.call(target);
const george = { name: 'george' };
const model = mixinModel(george);
model.on('change', data => console.log(data));
model.set('name', 'Sam');
/*
{
  prop: 'name',
  value: 'Sam'
}
*/

En transformant attrs en un identifiant privé, on retire toute trace de sa présence depuis l'API publique. La seule façon d'y accéder est d'utiliser les méthodes privilégiées qui sont exposées via la portée de la fermeture et qui ont, elles, accès aux données privées.

Dans l'exemple qui précède, mixinModel() enveloppe le mixin fonctionnel rawMixin(). En effet, il faut définir la valeur de this à l'intérieur de la fonction, ce que l'on fait avec Function.prototype.call().

Conclusion

Il est essentiel de comprendre le modèle d'héritage basé sur les prototypes avant d'écrire du code complexe basé sur ces notions. Pour des raisons de performances, il faut être conscient de la longueur de la chaîne des prototypes pour éventuellement la fragmenter si possible. Enfin, les prototypes natifs ne devraient jamais être étendus, à moins que cela ne soit strictement nécessaire pour la compatibilité avec de nouvelles fonctionnalités de JavaScript.

Étiquettes et contributeurs liés au document

Dernière mise à jour par : grandoc,