Créer un EventDispatcher en JavaScript similaire à la version ActionScript 3

UPDATE: Grâce à un commentaire de Lionel, j’ai découvert JS-Signals qui émule exactement le comportement que j’essayai de recréer (en beaucoup mieux). Je laisse l’article pour la forme mais si vous chercher à réaliser ce genre de fonctionnalité, dirigez vous vers JS-Signals: http://millermedeiros.github.com/js-signals/

Dans le dernier article, j’ai tenté d’expliqué comment rendre son application complètement modulaire grâce à un modèle évènementiel emprunté aux applications Flex / ActionScript 3:

Modifier le comportement d’une application modulaire grâce à des évènements

Comme expliqué à la fin de l’article, il n’existe pas de solution « native » en JavaScript pour écrire ce genre de comportement. Donc j’ai pris mon IntelliJ, 2h de temps, un très bon mix dubstep de Cutline et j’ai écrit un EventDispatcher en JavaScript similaire à celui que l’on peut utiliser en ActionScript.

Vous pouvez parachuter ce code n’importe où dans votre code, il n’y a aucune dépendance vers jQuery ou autre librairie:

 var util = util || {};
/**
 *
 * Lightweight Event Dispatcher class, similar to the EventDispatcher class in ActionScript 3
 * Supports multiple listeners, priorities and stop propagation.
 * Can be used as a local event dispatcher for a component (using composition) or as a pub/sub mechanism if global
 *
 * Annoted for Google Closure Compiler (http://code.google.com/closure/compiler/docs/js-for-compiler.html)
 * @example var bus = new util.EventDispatcher(this);
 * @param {Object} context Scope that will be passed to the event listener callback
 * @author Fabien Nicollet - fnicollet@gmail.com
 * @constructor
 */
util.EventDispatcher = function (context) {
    this.events = [];
    this.context = context;
};
/**
 * Internal map of events
 * @private
 * @type {Array.<Object>}
 */
util.EventDispatcher.prototype.events = null;
/**
 * Internal reference to the original context
 * @private
 * @type {Object}
 */
util.EventDispatcher.prototype.context = null;
/**
 * Add an event listener (callback for a specific event type.
 * Call return false if a listener to stop the event propagation
 * @example bus.addEventListener('someEventType', onSomeEventType);
 * @param {string} type Event type, typically a String that will be stored in your event class
 * @param {function(event)} callback Reference to the event handler
 * @param {Object=} context Optionnal scope for the event handler
 * @param {number=} priority Positive Number (0 by default). The higher the priority is, the sooner the event handler gets called
 */
util.EventDispatcher.prototype.addEventListener = function (type, callback, context, priority) {
    // default
    priority = priority || 0;
    // add an entry for this event type if not in the map already
    this.events[type] = this.events[type] || {};
    var listenerToInsert = {context:context, callback:callback, priority:priority};
    // same for listeners map (Array) for this event type
    if (this.events[type].listeners) {
        // insert at the right spot
        var listeners = this.events[type].listeners;
        var inserted = false;
        for (var i = 0, l = listeners.length; i < l; i++) {
            var listener = listeners[i];
            var eventPriority = listener.priority;
            if (priority < eventPriority) {
                listeners.splice(i, 0, listenerToInsert);
                inserted = true;
                break;
            }
        }
        if (!inserted) {
            listeners.push(listenerToInsert);
        }
    } else {
        this.events[type].listeners = [listenerToInsert];
    }
};
/**
 * Returns wether an event listener is registered for the specified event type (callback parameter specified) or if there is at least one listener specified for the event type (no callback parameter specified)
 * @example bus.hasEventListener('someEventType', onSomeEventType);
 * @example bus.hasEventListener('someEventType');
 * @param {string} type
 * @param {function(Object)=} callback
 * @return {boolean} true if an event listener is registered for the specified event type (callback parameter specified) or true if there is at least one listener specified for the event type (no callback parameter specified)
 */
util.EventDispatcher.prototype.hasEventListener = function (type, callback) {
    var listeners = this.events[type] ? this.events[type].listeners : null;
    if (!listeners) {
        return false;
    }
    // if no callback is provided, check if any callback is defined for this event
    if (!callback) {
        return listeners.length > 0;
    }
    // looking for a specific event
    for (var i = 0, l = listeners.length; i < l; i++) {
        var listener = listeners[i];
        if (listener.callback === callback) {
            return true;
        }
    }
    return false;
};
/**
 * Remove an event handler for a specific type. If no callback is specified, all the event listeners for this event type are removed
 * @example bus.removeEventListener('someEventType', onSomeEventType);
 * @example bus.removeEventListener('someEventType');
 * @param {string} type
 * @param {function(Object)=} callback
 */
util.EventDispatcher.prototype.removeEventListener = function (type, callback) {
    var listeners = this.events[type] ? this.events[type].listeners : null;
    if (!listeners || listeners.length < 1) {
        return false;
    }
    // not defining a callback = remove all listeners
    if (!callback) {
        this.events[type].listeners = [];
        return true;
    }
    for (var i = 0, l = listeners.length; i < l; i++) {
        var listener = listeners[i];
        if (listener.callback === callback) {
            listeners.splice(i, 1);
            return true;
        }
    }
    return false;
};
/**
 * Dispatch and event on the event dispatched that will be caught by one of the event listener if attached.
 * @example bus.dispatchEvent({type:'someEventType, data:{someData:'someDataValue'}});
 * @param {Object} event Any Object with a type property. Data can be passed to the event handler if there is a data property on the event object
 */
util.EventDispatcher.prototype.dispatchEvent = function (event) {
    var type = event.type;
    // default
    var listeners = this.events[type] ? this.events[type].listeners : null;
    if (!listeners || listeners.length < 1) {
        // no listeners for this event
        return;
    }
    for (var i = listeners.length - 1; i >= 0; i--) {
        var listener = listeners[i];
        var callback = listener.callback;
        // merge listener data and event triggered data
        var callbackContext = listener.context ? listener.context : this.context;
        var result = callback.call(callbackContext, event);
        if (result !== undefined && !result) {
            break;
        }
    }
};

Vous pouvez aussi retrouver cette classe sur GitHub:

https://github.com/fnicollet/HTML5Tutorial/blob/master/HTML5Tutorial/util/EventDispatcher.js

Et la version minifiée (1.2K):

https://raw.github.com/fnicollet/HTML5Tutorial/master/HTML5Tutorial/util/EventDispatcher.min.js

Cette « classe » JavaScript n’est pas strictement équivalente à EventDispatcher, car il m’a fallu m’adapter à certaines subtilités du langage comme la gestion du scope (contexte d’exécution de la callback).

Veuillez noter que cette version JavaScript fonctionne par composition (ajout d’une propriété de type EventDispatcher dans votre classe) plutôt que par héritage comme en ActionScript.

Exemples d’utilisation

Voici quelques exemples (7) pour vous montrer l’utilité de cette petite classe et les différences avec la version AS3. Ici, je crée un objet EventDispatcher au même niveau que le code de test pour simplifier les exemples. Veuillez notez que comme dans l’exemple 7, l’objet de type EventDispatcher peut-être une propriété de n’importe quel objet, que ce soit votre composant, votre « Mediator » ou votre « Facade ».

Vous pouvez trouver ces exemples sur une petite page de test que j’ai fait rapidement:

https://github.com/fnicollet/HTML5Tutorial/blob/master/HTML5Tutorial/EventDispatcherTest.html

Exemple 1 – Ecouter un évènement, propager un évènement

On commence par créer un EventDispatcher, la variable « bus »:

var bus = new util.EventDispatcher(this);

Puis on ajoute un écouteur d’évènement (event listener) sur ce bus:

bus.addEventListener("someEventType", function (event) {
            log("someEventType received");
        });

On écoute donc un évènement de type « someEventType » et on lui associe une callback que l’on définit en inline (directement dans l’appel).

Ensuite, on propage un évènement de type « someEventType » sur le bus pour déclencher la callback:

bus.dispatchEvent({type:"someEventType"});
        // logs "someEventType received"

Ici, pour simplifier l’exemple, je construit un objet directement en inline par la notation {propertyName:propertyValue}. Vous pourriez bien sûr, vous pouvez instancier un objet de votre choix, tant qu’il contient une propriété « type ».

Voilà ce que font la plupart des librairies de pub/sub. Voyons maintenant les autres fonctionnalités de cette classe.

Exemple 2 – Supprimer ou event listener associé à un type d’évènement (ou tous les event listener) avec removeEventListener

Dans l’exemple 1, on a ajouté un écouteur d’évènement avec addEventListener. On peut faire le supprimer avec la méthode removeEventListener:

bus.removeEventListener("someEventType");
// logs nothing
bus.dispatchEvent({type:"someEventType"})

Si vous ne passez pas le second argument à la méthode removeEventListener, tout les callbacks associées au type « someEventType » seront supprimées. Vous pouvez aussi cibler une callback en particulier:

var anotherListener = function (event) {
    log("anotherListener");
};
bus.addEventListener("someEventType", anotherListener);
bus.dispatchEvent({type:"someEventType"});
// logs "anotherListener"
bus.removeEventListener("someEventType", anotherListener);
// logs nothing
bus.dispatchEvent({type:"someEventType"});

Exemple 3 – Savoir si un event listener est enregistrée avec hasEventListener

Il peut être utile de savoir si un évènement est écouté ou pas. C’est ce que propose la méthode hasEventListener. Comme pour removeEventListener, vous pouvez savoir si un évènement est enregistrée (au moins un) ou si une callback en particulier est précisée:

// logs false
var hasEventListenerGlobal = bus.hasEventListener("someEventType");
var anotherListener = function (event) {
    log("anotherListener");
};
log("bus has event listener for someEventType?: " + hasEventListenerGlobal);
bus.addEventListener("someEventType", anotherListener);
hasEventListenerGlobal = bus.hasEventListener("someEventType");
// logs true
log("bus has event listener for someEventType?: " + hasEventListenerGlobal);
var hasEventListenerSpecific = bus.hasEventListener("someEventType", anotherListener);
// logs true
log("bus has event listener for a specific event listener : " + hasEventListenerSpecific);

Exemple 4 – Passer de la donnée avec l’évènement

Ce qui est intéressant, ce n’est pas seulement de lancer un « signal » mais bien de propager de l’information avec un évènement. Dans la méthode dispatchEvent, vous pouvez passer un Object qui a au moins une propriété « type ». Cet évènement sera repassé à l’event listener, vous pourrez donc récupérer ces informations à ce moment-là:

bus.addEventListener("someEventType", function (event) {
    log(event);
    for (var listenerDataKey in event) {
        log("Test 4 handler received with key / data: " + listenerDataKey + " / " + event[listenerDataKey]);
    }
}, {someDataFromTheEventListener:"someDataFromTheEventListenerValue"});
bus.dispatchEvent({type:"someEventType",
                    eventData1:"eventData1Value",
                    eventData2:"eventData2Value",
                    eventData3:"eventData3Value"
});
/* logs
 Test 4 handler received with key / data: type / someEventType
 Test 4 handler received with key / data: eventData1 / eventData1Value
 Test 4 handler received with key / data: eventData2 / eventData2Value
 Test 4 handler received with key / data: eventData3 / eventData3Value
 Test 4 handler received with key / data: someDataFromTheEventListener / someDataFromTheEventListenerValue
*/

Exemple 5 – Donner une priorité à un event listener

Voilà une des fonctionnalités les plus importantes de cette classe, la possibilité de passer une priorité lors de l’appel à addEventListener en passant un nombre entier en tant que 4ème paramètre. Plus la priorité est haute, plus l’event listener sera appelé en priorité (plus tôt).

Voici un petit exemple, avec des différentes priorités. Vous verrez que l’écouteur d’évènement avec la priorité la plus haute (8) sera appelé en premier:

bus.addEventListener("someEventType", function (event) {
    log("Event with priority 0 received");
}, null, 0);
bus.addEventListener("someEventType", function (event) {
    log("Event with priority 4 received");
}, null, 4);
bus.addEventListener("someEventType", function (event) {
    log("Event with priority 2 received");
}, null, 2);
bus.addEventListener("someEventType", function (event) {
    log("Event with priority 8 received");
}, null, 8);
bus.dispatchEvent({type:"someEventType"});
/* logs
 Event with priority 8 received
 Event with priority 4 received
 Event with priority 2 received
 Event with priority 0 received
 */

Exemple 6 – Priorité et arrêt de la propagation de l’évènement

Avec ce système de priorité, on a réussi à ordonner nos event listeners. Comme je l’avais précisé dans l’article précédent sur les applications modulaires, ce qui est intéressant, c’est de pouvoir couper la propagation de l’évènement. Pensez à de l’héritage, quand vous avez le choix d’appeler « super() » ou pas.

Pour stopper la propagation, il suffit dans un event listener de renvoyer false comme dans cet exemple:

bus.addEventListener("someEventType", function (event) {
    log("Event with priority 0 received");
}, null, 0);
bus.addEventListener("someEventType", function (event) {
    log("Event with priority 2 received");
}, null, 2);
bus.addEventListener("someEventType", function (event) {
    log("Event with priority 4 received");
    // stop propagation
    return false;
}, null, 4);
bus.addEventListener("someEventType", function (event) {
    log("Event with priority 8 received");
}, null, 8);
bus.dispatchEvent({type:"someEventType"});
/* logs
 Event with priority 8 received
 Event with priority 4 received
 // propagation stopped in listener with priority 4 by returning false
 */

Grâce à cela, vous pouvez mettre en place les mécanismes dont je vous ai parlé dans l’article précédent!

Exemple 7 – Modifier le scope d’appel de l’event listener

Là, c’est souvent la partie où on ne comprend pas ce qui se passe lorsque l’on a fait de l’ActionScript 3 par exemple.

Quand on travaille en ActionScript, le scope est (quasiment) toujours celui de la classe, donc on peut se passer des instructions « this. ». En JavaScript, « this » va être différent suivant le contexte d’utilisation, il faut donc toujours préfixer ses accès par « this. » mais surtout bien maîtriser c’est qu’est « this. » dans le contexte d’exécution. Il y a de très bons articles qui expliquent tout cela sur le net, je vous laisse chercher un peu.

Dans les exemples précédents, on avait notre « bus » directement dans le même scope que le reste du code de test, donc aucun problème de scope. Malheureusement, ce ne sera sûrement pas comme cela dans votre application.

Admettons maintenant que vous ayez un composant nommé « ComponentA » défini comme ceci:

    var test = test || {};
    /**
     * @constructor
     */
    test.ComponentA = function () {
        this.bus = new util.EventDispatcher(this);
    };

    test.ComponentA.prototype.bus = null;

Notez que lors de l’instanciation de notre EventDispatcher, on lui passe le contexte (scope) de base dans lequel seront effectués les écouteurs d’évènements. Ce qui signifie que lorsque vous exécutez ce code:

var compA = new test.ComponentA();
compA.bus.addEventListener("someEventType", function (event) {
    log(this); // logs test.ComponentA
});
compA.bus.dispatchEvent({type:"someEventType"});

Le « this » va renvoyer vers l’instance de ComponentA qui contient le « bus ». Un peu comme on a la propriété « target » en ActionScript qui contient une référence vers l’EventDispatcher qui a propagé l’évènement.

Seulement, vous vous rendrez compte à l’utilisation que le scope dans lequel vous allez vouloir exécuter votre event listener n’est pas celui de la classe qui contient le « bus » mais celui de l’appel à addEventListener.

Pour contourner cela, vous pouvez passer en 3ème paramètre de addEventListener, le contexte d’exécution de la callback:

// passing the outer context in the data parameter (3rd parameter).
compA.bus.addEventListener("someEventType", function (event) {
    log(this); // logs DOMWindow, which is the outer context
}, this);
compA.bus.dispatchEvent({type:"someEventType"});

Pour faire une comparaison, dans la méthode bind de jQuery, si vous souhaitez exécuter votre event listener dans un autre contexte, vous devez passer « this » dans les « data » qui sont passées à l’évènement et dans la callback, aller chercher votre var context = event.data.context. Assez lourd à écrire. Une autre solution consiste à faire passer la référence de la callback dans la méthode proxy de jQuery, ce qui fonctionne plutôt bien mais oblige à faire des copier-coller assez lourds.

Conclusion

Voilà, un article assez long mais je voulais présenter toutes les facettes de cette mini-classe. Bien sûr, c’est une version 1 et surtout le premier bout de code en JavaScript que je publie sur html5-tutorial.fr.

N’hésitez donc pas à me faire part de toute suggestion / correction dans les commentaires!

UPDATE: Grâce à un commentaire de Lionel, j’ai découvert JS-Signals qui émule exactement le comportement que j’essayai de recréer (en beaucoup mieux). Je laisse l’article pour la forme mais si vous chercher à réaliser ce genre de fonctionnalité, dirigez vous vers JS-Signals: http://millermedeiros.github.com/js-signals/

6 réflexions au sujet de « Créer un EventDispatcher en JavaScript similaire à la version ActionScript 3 »

  1. lionel

    Hello,

    Je vois plusieurs problème

    function Person(name){
    this.name = name;
    };
    Person.prototype.sayHello=function(){ return "hello "+this.name }
    var bob = new Person("bob");
    var sam = new Person("sam");
    //log true
    console.log(bob.sayHello === sam.sayHello);

    Ca empêche l’utilisation de ton dispatcher par plusieurs objets d’un même type qui souhaitent écouter les event d’une classe (en gros, le removeListener va enlever le premier callback qu’il trouvera). Ensuite l’autre problème, c’est celui du context. C’est bien de la passer au constructeur, ce qui veut dire que le this fera toujours référence à une seule et même classe, ce qui la aussi limite l’emploi des callback.

    Je pense que tu peux difficilement dissocier la function et son contexte, hélas.

    Et sinon pourquoi pas être parti sur une approche signal ? (en y ajoutant des priorité pour le coup) comme ici http://millermedeiros.github.com/js-signals/

    Répondre
  2. lionel

    oups j’ai commenté trop vite, javais pas vu que tu avais rajouté le context dans le addEventlistener et sa prise en compte dans le dispatch depuis le gist ;) c’est mieux l’utilisation avec des callback appartenant à des objets mais le removelistener risque toujours de foiré par contre.

    Lionel

    Répondre
  3. admin Auteur de l’article

    Salut Lionel et merci pour tes commentaires !
    Je n’avais pas vu cette implémentation de Signal en AS avant, dommage :P. Effectivement, on dirait que leur approche ressemble beaucoup à la mienne, il ne manque que l’histoire des priorité et du stop de la propagation.
    Je vais voir si je peux faire un fork de cette librairie pour l’adapter. Autant l’approche Signals en AS3 me plait pas des masses (pas « natif », je préfère les events) mais en JS, il n’y a rien donc autant partir sur cela. En plus, cela permet de mieux documenter sa classe vu que l’on a pas de « metadata » [Event] en JavaScript

    Fabien

    Répondre
  4. lionel

    Si javais su je t’aurais envoyer le lien plutot ;)

    ca pourrait être intéressant d’ajouter le stop propagation, il me semble que joa Ebert avait fait un fork d’AS3 signal avec ce genre de feature (stop, pause/resume, etc)

    J’ai commencé une lib de signal aussi, je pense que je vais y rajouter tout ça et surement modifier la gestion interne de stockage des évènements en reprenant le principe dans backbone (linkedList)

    Répondre
  5. admin Auteur de l’article

    En fait, ils font aussi le stop propagation, cf exemple « Stop/Halt Propagation (method 2) » avec un return false comme je le faisais ou avec un appel à « halt() ».
    J’aurai du chercher (encore) plus, ça m’aurait évité tout ça :P

    Merci pour le lien ;)
    Fab

    Répondre

Répondre à lionel Annuler la réponse.

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *