Objet javascript vs DOM

Pour créer un comportement graphique qui n'existe pas nativement dans un document web, on peut soit écrire un objet javascript dédié, soit se reposer sur les objets DOM disponibles. Comparaison des 2 techniques.

Présentation

Partons du principe que nous souhaitons créer un composant graphique web qui aurait les caractéristiques suivantes :

Propriétés du composant graphique
  • Une propriété « état » : « on » ou « off ».
Interaction avec le composant
  • Par l'interface graphique : click sur l'élément.
  • Par programmation à l'aide d'une API.
Caractéristique des états
  • Des styles différents.
  • Un contenu du composant différent.
Au niveau du code HTML
  • Les éléments à gérer seront identifiés par une classe « switch ».
  • L'état sera noté avec un attribut « data-state ».
<p class="switch" data-state="off">OFF</p>
Exemple de markup HTML pour rendre le composant graphique.

Il est important de noter ici que cet exemple ne sert qu'à illustrer le propos. Si nous devions vraiment construire un tel comportement graphique, l'utilisation d'une case à cocher, d'un label associé et d'une feuille de styles serait beaucoup plus judicieuse.

Avec ce mini cahier des charges, nous pourrions envisager 2 manières de construire le composant :

  • Avec un objet javascript dédié gérant l'élément HTML et fournissant une API adéquate.
  • Avec une « implémentation DOM », qui consiste à tirer parti des API mises à disposition par les objets DOM eux-mêmes, sans faire appel à une « surcouche » javascript.

Voyons comment mettre en place des 2 options afin d'évaluer les avantages et les inconvénients de chacune d'entre elles.

Création du composant

Objet javascript

Un objet chargé de gérer un tel composant graphique est assez simple à écrire : il nous suffit d'écouter l'action utilisateur (click de souris) et de mettre à disposition une méthode pour changer sont état (setState).

/**
 *  Objet de gestion d'un élément HTML pouvant posséder 2 états graphiques.
 *
 *  @param {HTMLElement} element Elément HTML à gérer.
 */
var Switcher = function (element) {
    this.element = element;
    this.element.addEventListener('click', this.onClick.bind(this), false);
};

/**
 *  Ecouteur de click sur l'élément.
 *
 *  @readonly
 *  @param {Event} e DOM Event
 */
Switcher.prototype.onClick = function (e) {
    this.setState(this.element.dataset.state === 'on' ? 'off' : 'on');
};

/**
 *  Définir l'état de l'élément.
 *
 *  @param {String} state Etat de l'élément ('on'|'off').
 */
Switcher.prototype.setState = function (state) {
    this.element.dataset.state = state;
    // do action
    this.element.innerHTML = state.toUpperCase();
};
Objet javascript Switcher pour gérer le composant graphique.

Implémentation DOM

L'implémentation DOM est un peu plus complexe dans le sens où, si l'écoute de l'interaction utilisateur (click) reste la même, on ne peut pas mettre à disposition une méthode de changement d'état(1). L'intéraction programmatique se fera en changeant directement la valeur de l'attribut data-state et, pour que l'on en soit informé, il faut créer un observateur de mutations.

/**
 *  Gestion d'un élément graphique à 2 états.
 */
var switcher = {
    /**
     *  Configuration de l'observateur de mutations.
     *
     *  @readonly
     *  @type Object
     */
    observerConfig: {
        attributes: true,
        attributeFilter: ['data-state']
    },
    
    /**
     *  Observateur de mutations d'un élement graphique.
     *
     *  @readonly
     *  @param {MutationRecord[]} mutations
     */
    observerCallback: function (mutations) {
        var target = mutations[0].target;
        // do action
        target.innerHTML = target.dataset.state.toUpperCase();
    },
    
    /**
     *  Initialiser la gestion d'un élément.
     *
     *  @param {HTMLElement} element Elément HTML à gérer.
     */
    init: function (element) {
        var observer = new MutationObserver(switcher.observerCallback);
        observer.observe(element, switcher.observerConfig);
        element.addEventListener('click', switcher.onClick, false);
    },
    
    /**
     *  Ecouteur de click sur l'élément.
     *
     *  @readonly
     *  @param {Event} e DOM Event
     */
    onClick: function (e) {
        var state = e.target.dataset.state;
        e.target.dataset.state = state === 'on' ? 'off' : 'on';
    }
};
Implémentation DOM pour gérer le composant graphique.

Initialisation au chargement de la page

Dans les 2 cas, l'initialisation du composant nécessite de récupérer tous les éléments HTML à gérer (portant la classe « switch ») puis dans un cas de créer une instance de l'objet et dans l'autre d'appeler la méthode d'initialisation.

var switchers = document.getElementsByClassName('switch');
for (var i = 0, n = switchers.length; i < n; i++) {
    var switcher = new Switcher(switchers[i]);
}
Instanciation des composants graphiques avec l'objet javascript.
var switchers = document.getElementsByClassName('switch');
for (var i = 0, n = switchers.length; i < n; i++) {
    switcher.init(switchers[i]);
}
Instanciation des composants graphiques avec « l'implémentation DOM ».

Discussion

A première vue, la création d'un objet javascript dédié à ce composant graphique parait plus simple et rapide, d'autant plus que l'implémentation DOM s'appuie sur un observateur de mutations, objet qui n'est pas compris par tous les navigateurs web du marché(2).

Utilisation du composant graphique

Quelque soit l'implémentation choisie, l'utilisation graphique du composant reste identique : un click sur l'élément modifie l'état et déclenche l'action associée. Là où les 2 implémentations diffèrent c'est lorsqu'un code tiers souhaite interagir avec le composant.

Avec l'objet javascript

L'API est portée par l'objet javascript Switcher. Il faudra donc :

  • Soit que le code tiers posséde une référence de l'instance visée, voir que ce soit lui qui la créée.
  • Soit que l'ensemble des instances créées au chargement de la page soient stockées en un lieu accessible par le code tiers. C'est le principe du patron de conception « registre ».
  • Soit que l'élément HTML lui-même possède une référence à l'objet Switcher qui le gère.

Voilà par exemple comment pourrait être appliqué ce dernier cas :

var Switcher = function (element) {
    this.element = element;
    this.element.addEventListener('click', this.onClick.bind(this), false);
    // Ajouter la référence à cet objet sur l'élément
    this.element.switcher = this;
};
Changement dans le constructeur de l'objet pour enregistrer l'instance de l'objet dans l'élément HTML.

Agir sur l'état du composant graphique de manière programmatique se fera alors de la manière suivante :

var element = document.getElementById('mySwitcher');
element.switcher.setState('off');
Action programmatique d'un code tiers sur l'objet javascript.

On voit ici le premier défaut de l'objet javascript : l'écriture du code tiers va obligatoirement dépendre (1) de la manière dont aura été stocké l'objet dans le document courant et (2) de l'API publique mise à disposition. Il y a donc une dépendance forte.

Avec l'implémentation DOM

L'avantage de l'implémentation DOM, c'est que le code tiers peut interragir directement avec l'élément HTML : il n'a absolument pas à se soucier du comment le composant graphique est géré puisque l'intéraction se base sur des API natives du DOM (édition d'un attribut HTML) ; la dépendance est faible.

var element = document.getElementById('mySwitcher');
element.dataset.state = 'off';
Action programmatique d'un code tiers après l'implémentation DOM.

Discussion

Voici donc le premier avantage évident de l'implémentation DOM par rapport à l'objet javascript : un code tiers souhaitant utiliser le composant grahique n'aura pas à savoir a priori comment ce composant a été créer / gérer. Il manipulera les API DOM aussi naturellement que s'il s'agissait d'un composant natif.

Emission et écoute d'événements

Allons plus loin et imaginons maintenant que notre composant graphique doit émettre un événement « switched » qui marquera le changement d'état.

Avec un objet javascript

Si on a utilisé un objet javascript, la gestion d'évenements nécessite la création de toute une API pour l'écoute et l'émission. Voilà à titre d'exemple à quoi elle pourrait ressembler :

/**
 *  Attacher un écouteur d'événement.
 *
 *  @param {String} type Type de l'événement
 *  @param {Function} listener Ecouteur de l'événement qui sera appelé lorsque
 *  l'événement est émis et qui recevra en paramètre l'objet émetteur.
 */
Switcher.prototype.addEventListener = function (type, listener) {
    if (!this.listeners) {
        this.listeners = {};
    }
    if (!this.listeners[type]) {
        this.listeners[type] = [];
    }
    if (this.listeners[type].indexOf(listener) === -1) {
        this.listeners[type].push(listener);
    }
};

/**
 *  Supprimer un écouteur d'événement.
 *
 *  @param {String} type Type de l'événement écouté.
 *  @param {Function} listener Ecouteur à supprimer.
 */
Switcher.prototype.removeEventListener = function (type, listener) {
    if (!this.listeners || !this.listeners[type]) {
        return;
    }
    var index = this.listeners[type].indexOf(listener);
    if (index > -1) {
        this.listeners[type].splice(index, 1);
    }
};

/**
 *  Emettre un événement
 *  
 *  @param {String} type Type de l'événement à émettre.
 */
Switcher.prototype.fireEvent = function (type) {
    if (!this.listeners || !this.listeners[type]) {
        return;
    }
    for (var i = 0, n = this.listeners[type].length; i < n; i++) {
        this.listeners[type][[i|this]];
    }
};
Exemple TRÈS SIMPLIFIÉ de l'implémentation d'émission d'événement.

Il faut ensuite changer la méthode setState pour qu'elle émette l'événement :

Switcher.prototype.setState = function (state) {
    this.element.dataset.state = state;
    // do action
    this.element.innerHTML = state.toUpperCase();
    // Fire event
    this.fireEvent('switched');
};
Modification de la méthode d'action pour émettre l'événement.

L'écoute de l'événement par un code tiers se fera alors ainsi :

var element = document.getElementById('mySwitcher');
element.switcher.addEventListener('switched', function (source) {
    // my action on switch
});
Ecoute de l'événement « switched ».

Avec l'implémentation DOM

Ici, pas besoin de créer d'API d'écoute / émission d'événements, elle existe déjà nativement dans les objets DOM ; il nous suffit donc d'émettre l'événement :

switcher.observerCallback = function (mutations) {
    var target = mutations[0].target;
    // do action
    target.innerHTML = target.dataset.state.toUpperCase();
    // fire event
    var event = new Event('switched');
    target.dispatchEvent(event);
};
Modification de la méthode d'action pour émettre un événement.

Un code tiers écoutera notre événement personnalisé comme il écouterait n'importe quel événement du DOM :

var element = document.getElementById('mySwitcher');
element.addEventListener('switched', function (source) {
    // my action on switch
});
Ecoute de l'événement « switched ».

Discussion

Encore une fois, l'implémentation DOM semble supérieure à l'objet javascript dédié : elle fournit une API complète pour la programmation événementielle qu'il serait coûteux de dupliquer intégralement dans un objet javascript.

Ressources et références

MutationObserver. Mozilla Developer Network,

HTMLElement.dataset. Mozilla Developer Network,

Using data-* attributes. Mozilla Developer Network,

VAN KESTEREN, Anne ; GREGOR, Aryeh ; HUNT, Lachlan, et al.. DOM4. W3C, . 5.3 Mutation observers