JavaScript, les bons éléments, façon 2020 : Des objets plus puissants

Avec l’ES5 (2009) puis les mises à jour suivantes, JavaScript a gagné en librarie standard, notamment sur sa primitive la plus… primitive : les objets.

Espaces de stockage clé > valeur très rapides, leur manipulation était néanmoins pénible : aucune propriété de base pour compter le nombre de propriétés, pas d’accesseurs pour accéder aux clés (héritées ou non, n’oublions pas que les objets peuvent avoir de l’héritage via leur prototype), ni aux valeurs.

De plus, il est parfois pertinent de copier des objets rapidement, ou au contraire de les sceller (c’est à dire d’empêcher d’ajouter de nouvelles propriétés).

La (très maigre) librairie standard en 2008

À la sortie de la "période de creux" de JavaScript (en gros: avant la sortie d’Internet Explorer 9), il y avait très peu de moyens de manipuler les objets. Les méthodes incluses dans le langage se comptaient sur le doigt d’une main et tout le reste tournait autour des boucles for in pour obtenir leurs clés/valeurs.

Toutes les méthodes "par défaut" étaient définies dans le prototype de base de n’importe quel objet, cela veut dire qu’un objet vide disposait quand même de certaines méthodes ! C’est pour cette raison que plus tard, lorsque l’on a rajouté des méthodes pour manipuler les objets, celles-ci ont été placées statiquement sur le constructeur Object (qui n’est pas hérité par les instances), et non dans le prototype.

Object.prototype.hasOwnProperty()

Permet de vérifier qu’une propriété existe et est propre à cet objet, c’est à dire qu’elle ne provient pas du prototype.

var o1 = { a: 3 };
o1.a; // 3
o1.hasOwnProperty('a'); // true

var o2 = (function () {
  var target = function () {};
  // Utilise o1 comme prototype pour les instances de {target}
  target.prototype = o1;

  return new target();
})();

o2.a; // 3
o2.hasOwnProperty('a'); // false

Pour vérifier qu’une propriété existe (et peut être simplement héritée), alors c’est le (seul) taf de l’opérateur in : 'a' in o2; // true.

Object.prototype.propertyIsEnumerable()

Teste si une propriété propre (non héritée) de l’objet en question est classifiée comme énumérable, c’est à dire si elle est itérée lors d’une boucle for in sur cet objet.

Néanmoins, avant ES5, il n’y a aucun moyen de modifier le statut énumérable d’une propriété, on peut juste savoir si elle l’est ou non.

// Toutes les propriétés ajoutées par l'utilisateur
// sont énumerables par défaut
o1.propertyIsEnumerable('a'); // true

// Certaines propriétés définies par défaut,
// comme .length sur les tableaux, ne sont pas énumérables
var arr = [];
arr.hasOwnProperty('length'); // true
arr.propertyIsEnumerable('length'); // false

// propertyIsEnumerable ne prend pas en compte la chaîne de prototype
o2.propertyIsEnumerable('a'); 
// => false, alors que la propriété apparaîtra bien 
// dans un for in sur cet objet !

Object.prototype.{toString,valueOf,toLocaleString}()

Ces propriétés sont ajoutées dans le cas où l’objet serait convertie en primitive (afin de ne pas lancer d’exception). Dans les faits, ces méthodes ne servent à rien : la méthode .toString() ne fait que renvoyer [object {this.constructor.name}] (soit généralement : [object Object]), il convient au programmeur de la surcharger sur ses propres objets.

Des ajouts bienvenus

Voyons ensemble les fonctionnalités ajoutées au cours du temps.

Object.{keys,values,entries}

La plus grande faiblesse des objets JavaScript était l’impossibilité d’obtenir rapidement les clés/valeurs propres à l’objets.

Très souvent, on était obligé de faire une boucle ressemblant à ça :

var obj = { a: 3, b: 5 };
var key, keys = [];

for (key in obj) {
  // Il faut s'assurer que la prop ne vient pas des prototypes
  if (obj.hasOwnProperty(key)) {
    keys.push(key);
  } 
}

// On a les clés dans {keys}

Maintenant, on a enfin des one-liners pour ce type d’opération !

Object.keys(obj); // ['a', 'b']
Object.values(obj); // [3, 5]
Object.entries(obj); // [['a', 3], ['b', 5]]

Ces trois méthodes concernent les propriétés propres (non héritées), qui sont énumérables. Les propriétés héritées ou configurées comme non énumérables ne seront pas présentes dans les tableaux renvoyés.

Notez que Object.keys() est bien plus répandu niveau compatibilité, values et entries datent de l’ES2017.

Object.defineProperty

Vous vous souvenez quand on disait que l’on pouvait voir si une propriété était énumérable mais que l’on pouvait rien y faire ? C’est révolu ! Accueillons Object.defineProperty, une méthode vraiment bienvenue.

Avec elle, nous allons pouvoir choisir les quatres slots internes qui composent des associations clés>valeurs dans des objets en JavaScript :

  • La valeur (évidemment)
  • Le statut writable, qui définit si on aura le droit de réécrire sur la propriété (utilisation de l’opérateur d’affectation sur cette propriété)
  • Le statut enumerable, qui définit si cette propriété sera itérée par le for in ou visible dans un Object.keys
  • Le statut configurable, qui définit si cet ensemble de slots peut être ré-écrit et si la valeur peut être supprimée via l’opérateur delete. Si configurable vaut false, la valeur peut néanmoins changer via l’opérateur d’affectation mais ne peut pas être supprimée de l’objet

Étant donné cet ensemble de valeurs, voici comment déclarer une propriété non énumérable, non configurable et non ré-inscriptible dans un objet (idéal si, par exemple, on souhaite étendre un prototype) :

// On va définir une méthode sur le prototype de String pour mettre la première lettre en majuscule
Object.defineProperty(String.prototype, 'uppercaseFirst', {
  // Les trois premières valeurs sont optionnelles,
  // par défaut, elles valent `false`
  configurable: false,
  writable: false,
  enumerable: false,
  value: function () {
    return this.slice(0, 1).toUpperCase() + this.slice(1);
  },
});

const s = 'coucou';
console.log(s.uppercaseFirst()); // 'Coucou'

for (const p in s) {
  console.log(p); // 0, 1, ...
  // On ne voit pas 'uppercaseFirst', notre propriété est bien “cachée” !
}

Getters et setters

L’ES5 a ajouté une fonctionnalité merveilleuse à JavaScript : les getters et setters.

Un getter (ou accesseur) est un moyen de calculer la valeur d’une propriété lors de l’accès à celle-ci, et jamais avant.

Le setter, lui, permet de simuler l’action de l’affectation sur une propriété qui n’existe pas vraiment.

Utilisés conjointement, les getters et setters permettent de réaliser des propriétés réalisant du type-checking, ou des actions supplémentaires comme sauvegarder la valeur en cache (style localStorage, cookie…).

Un getter ou un setter est toujours une fonction.

Ajouter des getters / setters à l’initialisation de l’objet

A l’initialisation d’un objet, il suffit d’utiliser les mots clés get et set avant le nom de la propriété. Une propriété peut très bien avoir un getter sans avoir de setter, et inversement.

const obj = {
  x: 1,
  y: 2,

  get vector() {
    return [this.x, this.y];
  },
  set vector(value) {
    if (value instanceof Array && value.length === 2) {
      this.x = value[0];
      this.y = value[1];
    }
    else {
      throw new Error('Unsupported value');
    }
  }
};

obj.vector; // [1, 2]
obj.vector = [3, 4];
obj.vector; // [3, 4]

// Uncaught Error: Unsupported value
obj.vector = 3;

Ajouter des getters / setters sur un objet existant

Il est possible de définir des getters et des setters via defineProperty (encore lui :D).

const obj = {
  x: 1,
  y: 2
};

Object.defineProperty(obj, 'vector', {
  configurable: true,
  writable: false,
  enumerable: true,

  // Ne pas préciser "value" !
  // Préciser une fonction get, set ou les deux :)
  get: function () {
    return [this.x, this.y];
  },
});

obj.vector; // [1, 2]

Object.assign

En JavaScript, la copie se fait par référence sur les objets : C’est à dire que lorsque vous utilisez l’opérateur d’affectation d’un objet vers une autre variable, vous copiez en réalité uniquement l’adresse de cet objet.

Si vous modifiez les propriétés de la "copie", vous allez également modifier l’original.

Dans certains langages, cela pourrait s’apparenter à une copie de pointeur.

const obj = { hello: 'world' };

const copy = obj;
copy === obj; // true ; L'opérateur === vérifie les références pour les objets

obj.a = 3;
copy.a; // 3 ! La copie est aussi modifiée

Si ce comportement est très pratique par exemple lorsqu’un objet est passé en paramètre à une fonction (cela ne coûte quasiment rien à l’ordinateur, c’est une copie d’entier), il peut être gênant si on veut créer à un instant T une copie conforme d’un objet, qui est amenée à évoluer différemment.

La norme ES6 (2015) amène avec elle une propriété bien pratique sur Object : assign. Comme son nom l’indique, elle permet d’assigner des propriétés à un objet.

Sa syntaxe est la suivate : Object.assign(target, source1 [, source2, ...]).

const obj = { hello: 'world' };

// On assigne toutes les propriétés de {obj} dans un nouvel objet vide
// Celui-ci est renvoyé, on peut donc s'en servir en sortie
const copy = Object.assign({}, obj);

copy.hello; // 'world'
copy.a = 3;
obj.a; // undefined ; C'est bien une copie sans liaison avec l'original !

Attention toutefois, la copie effectuée en "surface" : Si des objets sont présents dans des propriétés, alors c’est les références de ces objets qui seront copiés. De cette sorte, Object.assign s’apparente à un simple for in, cependant il copie uniquement les propriétés propres.

// Le code Object.assign vu plus haut est équivalent à celui-ci
const obj = { hello: 'world' };
const copy = {};

for (const key in obj) {
  if (obj.hasOwnProperty(key)) {
    copy[key] = obj[key];
  }
}

Object.create et Object.{get,set}PrototypeOf

Dernière partie sur les nouvelles méthodes d’Object, nous allons parler de prototype.

Coeur unique de JavaScript, ils étaient cependant en quelque sorte cachés avant l’apparition de l’ES5 (2009).

L’accès aux prototypes dans JavaScript

Précédemment, aucune norme standard ne donnait accès au prototype d’un objet ou primitive, il était caché dans un slot interne nommé [[Prototype]] (c’est un nom défini dans la spécification, il ne signifie rien dans le langage lui-même).

Des navigateurs comme Firefox implémentaient la propriété non-standard __proto__ pour y accéder, mais celle-ci était par exemple absente d’Internet Explorer avant la version 11.

Pour assigner un prototype à un objet, on emploie l’opérateur new en JavaScript : En effet, le .prototype d’une fonction est copié dans le slot [[Prototype]] d’un objet instancié avec cette dite-fonction.

Héritage via les prototypes au sein d’une fonction constructrice “Hello

C’est pour cette raison que lorsqu’on définit une méthode sur l’objet .prototype d’une fonction, celle-ci est disponible sur les instances uniquement, et pas sur le constructeur, car .prototype n’est pas le prototype de la fonction constructrice : Il sera le prototype des instances.

// Constructeur
function Hello() {}

// Ajout d'une fonction "say" dans le prototype qui sera donné aux instances
Hello.prototype.say = function () {
  console.log('Hello world!');
};

const instance = new Hello();
instance.__proto__ === Hello.prototype; // true
instance.say(); // Affiche "Hello world!" dans la console

Si on a accès au prototype d’un objet, il est simple de "simuler" l’opérateur new en JavaScript !

const instance = {};

instance.__proto__ = Hello.prototype;

// On appelle la fonction (constructeur) avec {this} qui vaut {instance}
// Ici, ceci n'aura aucun effet car le constructeur ne fait rien
Hello.call(instance);

// {instance} est une instance de {Hello} !

Si on n’a pas accès au prototype (parce que le navigateur ne supporte pas __proto__, par exemple), il est possible de créer un objet avec un prototype de notre choix en utilisant les constructeurs + new :

function createWithPrototype(proto) {
  // Un stub qui sera utilisé pour "donner" le prototype
  const target = function () {};

  target.prototype = proto;

  // On crée une "instance" de notre constructeur vide afin d'assigner 
  // le .prototype choisi dans slot interne de l'instance créée
  return new target();
}

const source = { a: 3 };
const child = createWithPrototype(source);

child.a; // 3
child.hasOwnProperty('a'); // false

Standardiser l’accès aux prototypes

Comme on l’a dit, la propriété __proto__ n’est pas standard et l’accès aux prototypes était chaotique. Il a donc été décider de fournir une manière fonctionnelle universelle d’accèder aux prototypes des objets.

Tout d’abord, Object.getPrototypeOf(source) a été ajouté afin de permettre d’accéder aux prototypes (cela paraît impensable, mais avant il pouvait être impossible de savoir "d’où" venaient les propriétés héritées !).

Une méthode supplémentaire, Object.setPrototypeOf(source, new_proto) est disponible pour effectuer l’action d’affectation. Celle-ci a été ajoutée bien plus tard, car il n’est pas trivial pour les moteurs de changer de prototype en cours de route, il est donc déconseiller de s’en servir.

Il est possible de créer un objet avec un prototype donné avec Object.create(proto), celle-ci fait l’équivalent de notre fonction createWithPrototype définie précédemment, avec une subtilité : il est possible de créer un objet sans prototype en précisant null en paramètre.

Ceci permet d’avoir des objets parfaitement "propres", qui ne disposent pas de méthode .hasOwnProperty() notamment.

const obj_without_proto = Object.create(null);

// Il n'hérite pas de Object.prototype
typeof obj_without_proto.hasOwnProperty; // 'undefined'

Object.getPrototypeOf(obj_without_proto); // null

// Lui assigne un prototype
Object.setPrototypeOf(obj_without_proto, Object.prototype);

typeof obj_without_proto.hasOwnProperty; // 'function'

La fin

Cette partie est terminée !

La suivante parlera de la déstructuration d’objets et tableaux, de la syntaxe courte des objets et, très important, des itérateurs !

À bientôt 😀 !

Article précédent : let, const, Map et SetArticle suivant : Itérateurs, générateurs

Laisser un commentaire