Bonjour ! Ces temps-ci, j'ai lu pas mal de ressources et fait pas mal d'essais sur les objets en JS, sur leurs prototypes et ce qu'il se passe quand on utilise un constructeur.
Ça ne fait pas de mal de mettre les choses au clair ; si ça vous intéresse, bonne lecture !

Qu'est-ce qu'un objet en JavaScript ?

Les objets JS sont des ensembles de couples clé-valeur appelés propriétés.

Pouvez-vous dire quelles valeurs JavaScript sont des objets parmi celles-ci ?

Héritage et chaîne de prototypes

Les objets peuvent disposer d'un lien vers un prototype.
Les prototypes étant eux-même des objets, cela constitue une chaîne d'objets reliés par les prototypes.

Lorsqu'on tente d'accéder à une propriété que l'objet ne possède pas, la chaîne de prototypes est parcourue objet par objet, jusqu'à trouver la propriété. Elle est alors utilisée sur l'objet de départ.

Un objet disposant d'une chaine de prototypes constituée de deux objets :
o → po → ppo ; o - foo: 1 ; po - foo: 2, bar: 2 ; ppo - baz: 3

Pouvez vous dire ce que renverront les accès aux propriétés suivants ?


o.foo;

On cherche une propriété nommée 'foo' dans l'ensemble des propriété de o. On la trouve : on renvoie donc sa valeur : 1.
À noter que la propriété foo de l'objet po (prototype de o) n'est pas utilisée, car on a trouvé une propriété plus tôt dans la chaîne de prototype.



o.bar;

On cherche une propriété nommée 'bar' dans l'ensemble des propriété de o. On ne la trouve pas.
On considère ensuite po, le prototype de o : on y trouve la propriété recherchée qui nous permet de renvoyer 2.



o.baz;

On cherche une propriété nommée 'baz' dans l'ensemble des propriété de o. On ne la trouve pas.
Comme précédemment, on remonte dans la chaîne de prototype jusqu'à trouver un prototype qui contient la propriété recherchée.
On finit par arriver à ppo ce qui nous permet de renvoyer la valeur 3.


Nous venons de constater comment fonctionne l'héritage en JavaScript. Un objet hérite d'un autre via son prototype, qui lui permet de faire comme si il disposait lui-même des propriétés de son parent.

Pour créer un objet héritant d'un objet obj, il faut donc créer un objet dont le prototype est obj. Il existe une fonction pour cela : Object.create (doc). Voici un exemple montrant l'héritage d'objet avec cette fonction :


var inheritedObject = {
    foo: 'bar',
    baz: function () { return this.foo; }
};

var o = Object.create(inheritedObject);

// o n'a pas de clé dans ses propres propriétés :
Object.keys(o); // []

// Mais on peut accéder aux propriétés de son prototype :
o.foo; // 'bar'
o.baz(); // 'bar'

// On peut définir la propriété 'foo' sur l'objet o :
o.foo = 2;

// La fonction de inheritedObject renvoie maintenant 2, car elle est appelée
// sur o dont la propriété 'foo' vaut 2.
o.baz(); // 2

// On supprime la propriété 'foo' de l'objet o :
delete o.foo;

// On constate qu'on accède de nouveau à 'bar' : la propriété 'foo' de o
// n'existant plus, on se rabat à nouveau sur celle de son prototype.
o.foo; // 'bar'
o.baz(); // 'bar'

// On change la valeur de la propriété 'foo' dans le prototype :
inheritedObject.foo = 42;
o.baz(); // 42

Les constructeurs

Les constructeurs sont des fonctions. La seule différence avec une fonction non-constructeur, c'est qu'un constructeur est une fonction qui a été appelée via l'opérateur new.

Lorsqu'une fonction est appelée avec new :

Cela signifie que var o = new Ctor() correspond à peu près à ceci :


var o = Object.create(Ctor.prototype, {
    constructor: { value: Ctor, writable: true, enumerable: false, configurable: false },
    __proto__: { value: Ctor.prototype, writable: true, enumerable: false, configurable: false },
});
Ctor.call(this);

Voici donc une implémentation approximative de l'opérateur new :


function construct() {
    var ctor = [].shift.call(arguments);
    var instance = Object.create(ctor.prototype, {
        constructor: { value: ctor, writable: true, enumerable: false, configurable: false },
        __proto__: { value: ctor.prototype, writable: true, enumerable: false, configurable: false },
    });
    ctor.apply(instance, arguments);
    return instance;
}

// Exemple :
function Foo(bar) {
    this.bar = bar;
}

var o = construct(Foo, 'bar'); // ~= var o = new Foo('bar');

Les constructeurs sont donc un moyen simple de définir comment initialiser des objets partageant un même prototype : il suffit d'écrire une fonction réalisant l'initialisaton et disposant du prototype à partager, puis d'utiliser l'opérateur new pour construire des objets avec.

Héritage de constructeurs

Du coup, on aimerait bien avoir un constructeur qui hérite d'un autre. Par exemple, je souhaite créer un constructeur pour fabriquer des objets qui préfixent toutes les lignes d'un flux de texte par une chaîne de caractères.

Ça tombe bien, NodeJS propose des constructeurs pour fabriquer des stream. Idéalement, il faudrait que j'étende le comportement d'un flux, car je souhaite obtenir un constructeur de flux qui fait quelque chose de spécifique.

Le but du jeu est donc de créer un constructeur Prefixer(prefixFn) qui permette de préfixer toutes les lignes d'un flux de texte avec la chaîne retournée par la fonction fournie. Je veux pouvoir l'utiliser de la façon suivante :


// Création d'un flux lisant le fichier dont on passe le chemin en paramètre :
var filePath = process.argv.slice(2).join(' ');
var fileStream = fs.createReadStream(filePath);

// Construction d'un préfixeur qui ajoute la date au début de toutes les lignes :
var prefixer = new Prefixer(function () { return new Date() + ': '; });

// On envoie le contenu du fichier dans notre préfixeur, qui envoie le résultat sur la sortie standard :
fileStream.pipe(prefixer).pipe(process.stdout);

Voici une implémentation du constructeur Prefixer :


function Prefixer(prefixFn) {
    this.prefixFn = prefixFn;
}

Prefixer.prototype._transform = function Prefixer__transform(chunk, encoding, callback) {
    var chunkString = chunk.toString(encoding !== 'buffer' ? encoding : null);
    callback(null, chunkString.replace(/^/gm, this.prefixFn()));
};

Tout ce qu'il lui manque, c'est d'hériter du prototype de TransformStream. Il faut donc que l'on définisse le prototype de Prefixer comme étant un objet qui hérite du prototype de TransformStream ; on ne veut pas donner ce dernier directement, sinon en modifiant le prototype de notre constructeur on modifiera également celui de TransformStream.

Voici une implémentation possible :


var TransformStream = require('stream').Transform;

function Prefixer(prefixFn) {
    this.constructor.super_.call(this);
    this.prefixFn = prefixFn;
}
Prefixer.super_ = TransformStream;
Prefixer.prototype = Object.create(TransformStream.prototype, {
    constructor: { value: Prefixer, writable: true, enumerable: false, configurable: false }
});

Prefixer.prototype._transform = function Prefixer__transform(chunk, encoding, callback) {
    var chunkString = chunk.toString(encoding !== 'buffer' ? encoding : null);
    callback(null, chunkString.replace(/^/gm, this.prefixFn()));
};

Et voilà, désormais on peut accéder aux méthodes des flux de transformation natifs de NodeJS (ici on appelle pipe()) sur les objets que notre nouveau constructeur fabriquera !

Maintenant qu'on a compris, on peut directement utiliser la méthode utilitaire d'héritage fournie par NodeJS ; voici ce que donne le script à la fin :


var fs = require('fs');
var inherits = require('util').inherits;
var TransformStream = require('stream').Transform;

function Prefixer(prefixFn) {
    this.constructor.super_.call(this);
    this.prefixFn = prefixFn;
}
inherits(Prefixer, TransformStream);

Prefixer.prototype._transform = function Prefixer__transform(chunk, encoding, callback) {
    var chunkString = chunk.toString(encoding !== 'buffer' ? encoding : null);
    callback(null, chunkString.replace(/^/gm, this.prefixFn()));
};


// On peut appeler le script en ligne de commande,
// en passant en paramètre le chemin du fichier à lire.
var filePath = process.argv.slice(2).join(' ');
var fileStream = fs.createReadStream(filePath);

var prefixer = new Prefixer(function () {
    return new Date() + ': ';
});

fileStream.pipe(prefixer).pipe(process.stdout);