Gestion des Objets en JavaScript : Propriétés et Modèles de Création

Alors que les langages de programmation orientée objet (POO) classiques reposent souvent sur la notion de classes pour encapsuler les attributs et comportements communs aux instances, JavaScript adopte une approche fondamentalement différente. Selon le standard ECMA-262, un objet JavaScript est une collection non ordonnée de propriétés. Ces propriétés peuvent contenir des valeurs primitives, d'autres objets ou des fonctions. On peut simplement conceptualiser un objet JavaScript comme un dictionnaire ou une carte où chaque clé (nom de propriété) donne accès à une valeur correspondante.

I. Explorer la Nature des Objets en JavaScript

Il existe plusieurs manières de construire des objets en JavaScript, des plus simples aux plus structurées.

1. Création via le constructeur global Object

Cette méthode implique d'instancier explicitement l'objet Object, puis d'y attacehr des propriétés et des fonctions.

let elementUi = new Object();
elementUi.idWidget = 'boutonPrincipal';
elementUi.taillePx = 200;
elementUi.obtenirId = function() {
    return this.idWidget;
};
console.log(elementUi.obtenirId()); // boutonPrincipal

2. Utilisation de la notation littérale d'objet

Largement plébiscitée pour sa clarté et sa compacité, cette notation permet de définir la structure de l'objet de manière directe.

let configurationReseau = {
    adresseIp: '192.168.1.1',
    portEcoute: 8080,
    afficherConfiguration: function() {
        return `IP: ${this.adresseIp}, Port: ${this.portEcoute}`;
    }
};
console.log(configurationReseau.afficherConfiguration()); // IP: 192.168.1.1, Port: 8080

Les objets JavaScript sont intrinsèquement flexibles. Vous pouvez étendre leur structure en ajoutant de nouvelles propriétés avec l'opérateur ., ou les réduire en supprimant des propriétés à l'aide de l'opérateur delete (ce qui les rendra undefined).

configurationReseau.protocole = 'HTTP'; // Ajout d'une propriété
console.log(configurationReseau.protocole); // HTTP

delete configurationReseau.portEcoute; // Suppression de la propriété
console.log(configurationReseau.portEcoute); // undefined (la propriété n'existe plus)

II. Caractéristiques des Propriétés d'Objet

La cinquième édition d'ECMA-262 a introduit des attributs internes pour les propriétés d'objet, invisibles directement mais essentiels au fonctionnement du moteur JavaScript. On distingue deux catégories de propriétés : les propriétés de données et les propriétés accesseurs.

1. Propriétés de données

Une propriété de données est un conteneur pour une valeur directement manipulable (lecture/écriture). Son comportement est régi par quatre attributs internes :

  • [[Configurable]]: Détermine si la propriété peut être supprimée, reconfigurée (passer d'une propriété de données à accesseur, ou modifier ses attributs), ou si ses attributs peuvent être altérés. Par défaut: true.
  • [[Enumerable]]: Indique si la propriété sera énumérée lors d'une boucle for-in. Par défaut: true.
  • [[Writable]]: Contrôle si la valeur de la propriété peut être modifiée. Par défaut: true.
  • [[Value]]: Représente la valeur effective de la propriété. Par défaut: undefined.

Pour modifier ces attributs par défaut, on utilise la fonction statique Object.defineProperty(). Elle requiert trois arguments : l'objet concerné, le nom de la propriété, et un objet descripteur listant les attributs à définir (configurable, enumerable, writable, value).

let parametresCache = {};

Object.defineProperty(parametresCache, 'maxTaille', {
    configurable: false, // Impossible de supprimer ou reconfigurer
    writable: false,     // Impossible de modifier la valeur
    value: 50
});

console.log(parametresCache.maxTaille); // 50

delete parametresCache.maxTaille; // Tentative de suppression (échoue, potentiellement erreur en mode strict)
parametresCache.maxTaille = 100; // Tentative de modification (échoue, potentiellement erreur en mode strict)

console.log(parametresCache.maxTaille); // 50 (la valeur n'a pas été altérée)

Il est crucial de comprendre qu'une fois configurable défini à false, il est impossible de le redéfinir à true pour la même propriété sans erreur.

2. Propriétés accesseurs

Ces propriétés ne stockent pas de valeur directement mais sont définies par un couple de fonctions : un getter et un setter. Le getter est invoqué lors de la lecture de la propriété, et le setter lors de l'écriture. Elles possèdent également quatre attributs :

  • [[Configurable]]: Identique à celui des propriétés de données.
  • [[Enumerable]]: Identique à celui des propriétés de données.
  • [[Get]]: La fonction invoquée lors de la lecture. Par défaut: undefined.
  • [[Set]]: La fonction invoquée lors de l'écriture. Par défaut: undefined.

Les propriétés accesseurs doivent impérativement être définies avec Object.defineProperty().

let capteurTemp = {
    _valeurInterne: 20 // Préfixe underscore pour les propriétés internes
};

Object.defineProperty(capteurTemp, 'temperatureCelsius', {
    get: function() {
        return this._valeurInterne;
    },
    set: function(nouvelleValeur) {
        if (nouvelleValeur >= -50 && nouvelleValeur <= 150) {
            this._valeurInterne = nouvelleValeur;
        } else {
            console.error("Température hors limites!");
        }
    },
    enumerable: true
});

console.log(capteurTemp.temperatureCelsius); // 20
capteurTemp.temperatureCelsius = 25;
console.log(capteurTemp.temperatureCelsius); // 25
capteurTemp.temperatureCelsius = 200; // Affiche l'erreur
console.log(capteurTemp.temperatureCelsius); // 25 (valeur inchangée)

La fonction Object.defineProperties() permet de définir simultanément plusieurs propriétés et leurs attributs.

let interfaceUtilisateur = {};

Object.defineProperties(interfaceUtilisateur, {
    _modeActif: {
        value: 'clair',
        writable: true
    },
    theme: {
        get: function() {
            return `Le thème actuel est ${this._modeActif}.`;
        },
        set: function(nouveauTheme) {
            if (['clair', 'sombre'].includes(nouveauTheme)) {
                this._modeActif = nouveauTheme;
            } else {
                console.warn("Thème non supporté.");
            }
        },
        enumerable: true
    }
});

console.log(interfaceUtilisateur.theme); // Le thème actuel est clair.
interfaceUtilisateur.theme = 'sombre';
console.log(interfaceUtilisateur.theme); // Le thème actuel est sombre.

Pour obtenir les descripteurs d'une propriété existante, utilisez Object.getOwnPropertyDescriptor().

let descripteurTheme = Object.getOwnPropertyDescriptor(interfaceUtilisateur, 'theme');
console.log(descripteurTheme.get); // [Function: get]
console.log(descripteurTheme.enumerable); // true

III. Modèles de Construction d'Objets

Les techniques précédentes sont adaptées pour la création d'objets uniques. Pour générer plusieurs objets partageant une structure et des comportements similaires, des modèles de conception sont plus efficaces.

1. Le Modèle de l'Usine (Factory Pattern)

Ce modèle utilise une fonction qui encapsule la logique de création d'objets, puis retourne la nouvelle instance. Bien qu'il réduise la répétition de code, il ne fournit pas de moyen direct pour identifier le "type" spécifique de l'objet créé, car tous sont des instances de Object.

function creerElement(tag, classes) {
    let nouvelElement = new Object();
    nouvelElement.tagHtml = tag;
    nouvelElement.classesCss = classes;
    nouvelElement.genererHtml = function() {
        return `<${this.tagHtml} class="${this.classesCss.join(' ')}">${this.tagHtml}>`;
    };
    return nouvelElement;
}

let bouton = creerElement('button', ['btn', 'btn-primary']);
let divConteneur = creerElement('div', ['container', 'fluid']);

console.log(bouton.genererHtml()); // <button class="btn btn-primary"></button>
console.log(divConteneur.genererHtml()); // <div class="container fluid"></div>

2. Le Modèle du Constructeur

Ce modèle emploie une fonction conçue spécifiquement pour servir de constructeur. Les propriétés et méthodes sont assignées directement à this, et l'opérateur new est indispensable pour instancier l'objet. Ce modèle offre l'avantage d'une identification claire du type d'objet (ex: instance instanceof Constructeur).

function UtilisateurSession(id, nomCompte) {
    this.idSession = id;
    this.nomCompte = nomCompte;
    this.timestampConnexion = new Date();
    this.getInfosConnexion = function() {
        return `ID: ${this.idSession}, Compte: ${this.nomCompte}, Connecté depuis: ${this.timestampConnexion.toLocaleString()}`;
    };
}

let user1 = new UtilisateurSession(1, 'johndoe');
let user2 = new UtilisateurSession(2, 'janesmith');

console.log(user1.getInfosConnexion());
console.log(user1 instanceof UtilisateurSession); // true
console.log(user1.constructor === UtilisateurSession); // true

L'opération new UtilisateurSession(...) suit ces étapes :

  1. Un objet JavaScript neuf et vide est créé.
  2. Le contexte d'exécution (this) de la fonction UtilisateurSession est lié à ce nouvel objet.
  3. Le code du constructeur est exécuté, initialisant les propriétés et méthodes de l'objet.
  4. L'objet fraîchement construit est renvoyé implicitement.

L'inconvénient majeur de ce modèle réside dans la duplication des méthodes. Chaque instance de UtilisateurSession obtiendra sa propre copie de la fonction getInfosConnexion, ce qui peut engendrer un gaspillage de mémoire significatif si le nombre d'instances est élevé. Bien qu'il soit possible d'externaliser la fonction et de la référencer, cela compromet l'encapsulation.

3. Le Modèle du Prototype

Chaque fonction en JavaScript est dotée d'une propriété prototype, qui référence un objet. Cet objet prototype est partagé par toutes les instances créées à l'aide du constructeur de cette fonction. Ainsi, les propriétés et méthodes définies sur le prototype sont partagées par toutes les instances, optimisant l'utilisation de la mémoire.

function ComposantGraphique() {
    // Les propriétés uniques à chaque instance peuvent être définies ici
}

ComposantGraphique.prototype.couleurDefaut = 'bleu'; // Propriété partagée
ComposantGraphique.prototype.afficher = function() { // Méthode partagée
    return `Affichage du composant de couleur ${this.couleurDefaut}.`;
};
ComposantGraphique.prototype.dependances = []; // ATTENTION aux types référence !

let boutonWidget = new ComposantGraphique();
boutonWidget.couleurDefaut = 'rouge'; // Crée une propriété sur l'instance, ne modifie pas le prototype
boutonWidget.dependances.push('jQuery'); // Modifie le tableau sur le prototype !

let zoneTexteWidget = new ComposantGraphique();

console.log(boutonWidget.afficher === zoneTexteWidget.afficher); // true (méthode partagée)
console.log(boutonWidget.couleurDefaut); // rouge (propriété de l'instance)
console.log(zoneTexteWidget.couleurDefaut); // bleu (propriété du prototype)
console.log(zoneTexteWidget.dependances); // ['jQuery'] (impacté par l'instance 'boutonWidget')

Lorsqu'une propriété ou méthode est sollicitée sur un objet, JavaScript recherche d'abord sur l'instance elle-même. Si elle n'est pas trouvée, la recherche se poursuit le long de la chaîne de prototypes. Une propriété définie sur l'instance masque celle portant le même nom sur le prototype.

Distinction : in vs. hasOwnProperty()

L'opérateur in vérifie la présence d'une propriété sur l'objet ou n'importe où dans sa chaîne de prototypes. À l'inverse, hasOwnProperty() ne teste que les propriétés qui appartiennent directement à l'instance elle-même.

console.log('couleurDefaut' in boutonWidget); // true
console.log(boutonWidget.hasOwnProperty('couleurDefaut')); // true (surchargé sur l'instance)
console.log('couleurDefaut' in zoneTexteWidget); // true
console.log(zoneTexteWidget.hasOwnProperty('couleurDefaut')); // false (vient du prototype)

Extension des Prototypes Natifs

Il est possible d'étendre les prototypes des objets natifs de JavaScript, tels que String. Cependant, cette pratique est généralement déconseillée dans les applications de production pour éviter les risques de conflits et de comportements inattendus.

String.prototype.supprimerEspacesFin = function() {
    return this.replace(/\s+$/, '');
};

let texteBrut = '  Un texte avec des espaces  ';
console.log(`"${texteBrut.supprimerEspacesFin()}"`); // "  Un texte avec des espaces"

Les inconvénients du modèle du prototype sont significatifs : il n'y a pas de moyen direct de passer des paramètres d'initialisation distincts à chaque instance, et le partage de propriétés de type référence (comme les tableaux ou objets) peut conduire à des modifications inattendues entre les instances, comme illustré avec ComposantGraphique.prototype.dependances.

4. Le Modèle Combiné (Constructeur + Prototype)

Ce modèle, le plus fréquemment adopté, fusionne les atouts des modèles constructeur et prototype. Les propriétés spécifiques à l'instance (qui peuvent varier) sont définies dans le constructeur, tandis que les méthodes et les propriétés qui doivent être partagées sont placées sur le prototype. Cette approche assure des valeurs uniques par instance, permet l'initialisation avec des paramètres, et optimise la consommation de mémoire en partageant les fonctions.

function TacheProjet(titre, priorite) {
    this.titre = titre;
    this.priorite = priorite;
    this.etapes = []; // Tableau unique par instance
}

TacheProjet.prototype = {
    // Essentiel : restaurer le lien vers le constructeur, car la notation littérale le rompt
    constructor: TacheProjet,
    ajouterEtape: function(etape) {
        this.etapes.push(etape);
    },
    obtenirStatut: function() {
        return `Tâche "${this.titre}" (Priorité: ${this.priorite}), ${this.etapes.length} étapes définies.`;
    }
};

let devTask = new TacheProjet('Développement Front-end', 'Haute');
devTask.ajouterEtape('Concevoir UI');
devTask.ajouterEtape('Implémenter Composants');

let docTask = new TacheProjet('Rédiger Documentation', 'Moyenne');
docTask.ajouterEtape('Collecter informations');
docTask.ajouterEtape('Structurer document');

console.log(devTask.obtenirStatut()); // Tâche "Développement Front-end" (Priorité: Haute), 2 étapes définies.
console.log(devTask.etapes);     // ['Concevoir UI', 'Implémenter Composants']
console.log(docTask.etapes);     // ['Collecter informations', 'Structurer document'] (leurs tableaux sont distincts)
console.log(devTask.obtenirStatut === docTask.obtenirStatut); // true (méthode partagée)

Ce modèle offre un excellent compromis entre flexibilité, gestion de la mémoire et encapsulation.

5. Le Modèle à Prototype Dynamique

Ce modèle est une extension raffinnée du modèle combiné. Il intègre toute la logique de construction et de définition du prototype directement dans la fonction constructrice. L'astuce consiste à vérifier si les méthodes partagées ont déjà été définies sur le prototype et, si ce n'est pas le cas (généralement lors de la première instanciation), de les définir. Cela garantit que les méthodes du prototype ne sont créées qu'une seule fois.

function GestionnaireLog(niveauLog, cible) {
    this.niveau = niveauLog;
    this.cible = cible;
    this.messages = [];

    // Ce bloc est exécuté UNIQUEMENT lors de la première création d'une instance
    if (typeof GestionnaireLog.prototype.logMessage !== 'function') {
        GestionnaireLog.prototype.logMessage = function(msg) {
            const timestamp = new Date().toISOString();
            this.messages.push(`[${timestamp}] [${this.niveau}] ${msg}`);
            console.log(`Log sur ${this.cible}: ${msg}`);
        };

        GestionnaireLog.prototype.getHistorique = function() {
            return this.messages.join('\n');
        };
        // Optionnel: si vous utilisez une syntaxe littérale pour le prototype, réaffectez le constructeur
        // GestionnaireLog.prototype.constructor = GestionnaireLog;
    }
}

let systemeLog = new GestionnaireLog('INFO', 'console');
systemeLog.logMessage('Application démarrée.');

let auditLog = new GestionnaireLog('AUDIT', 'fichier');
auditLog.logMessage('Accès utilisateur root.');

console.log(systemeLog.getHistorique());
console.log(auditLog.getHistorique());
console.log(systemeLog.logMessage === auditLog.logMessage); // true (méthodes de prototype partagées)

Ce modèle conserve les avantages du modèle combiné tout en offrant une encapsulation plus élégante en regroupant toute la configuraton de l'objet dans un seul endroit : le constructeur.

Étiquettes: JavaScript POO objets prototype Design Patterns

Publié le 17 juillet à 20h50