adobe/brackets

View on GitHub
src/utils/EventDispatcher.js

Summary

Maintainability
A
2 hrs
Test Coverage
/*
 * Copyright (c) 2014 - present Adobe Systems Incorporated. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 */

/**
 * Implements a jQuery-like event dispatch pattern for non-DOM objects:
 *  - Listeners are attached via on()/one() & detached via off()
 *  - Listeners can use namespaces for easy removal
 *  - Listeners can attach to multiple events at once via a space-separated list
 *  - Events are fired via trigger()
 *  - The same listener can be attached twice, and will be called twice; but off() will detach all
 *    duplicate copies at once ('duplicate' means '===' equality - see http://jsfiddle.net/bf4p29g5/1/)
 *
 * But it has some important differences from jQuery's non-DOM event mechanism:
 *  - More robust to listeners that throw exceptions (other listeners will still be called, and
 *    trigger() will still return control to its caller).
 *  - Events can be marked deprecated, causing on() to issue warnings
 *  - Easier to debug, since the dispatch code is much simpler
 *  - Faster, for the same reason
 *  - Uses less memory, since $(nonDOMObj).on() leaks memory in jQuery
 *  - API is simplified:
 *      - Event handlers do not have 'this' set to the event dispatcher object
 *      - Event object passed to handlers only has 'type' and 'target' fields
 *      - trigger() uses a simpler argument-list signature (like Promise APIs), rather than requiring
 *        an Array arg and ignoring additional args
 *      - trigger() does not support namespaces
 *      - For simplicity, on() does not accept a map of multiple events -> multiple handlers, nor a
 *        missing arg standing in for a bare 'return false' handler.
 *
 * For now, Brackets uses a jQuery patch to ensure $(obj).on() and obj.on() (etc.) are identical
 * for any obj that has the EventDispatcher pattern. In the future, this may be deprecated.
 *
 * To add EventDispatcher methods to any object, call EventDispatcher.makeEventDispatcher(obj).
 */
define(function (require, exports, module) {
    "use strict";

    var _ = require("thirdparty/lodash");

    var LEAK_WARNING_THRESHOLD = 15;


    /**
     * Split "event.namespace" string into its two parts; both parts are optional.
     * @param {string} eventName Event name and/or trailing ".namespace"
     * @return {!{event:string, ns:string}} Uses "" for missing parts.
     */
    function splitNs(eventStr) {
        var dot = eventStr.indexOf(".");
        if (dot === -1) {
            return { eventName: eventStr };
        } else {
            return { eventName: eventStr.substring(0, dot), ns: eventStr.substring(dot) };
        }
    }


    // These functions are added as mixins to any object by makeEventDispatcher()

    /**
     * Adds the given handler function to 'events': a space-separated list of one or more event names, each
     * with an optional ".namespace" (used by off() - see below). If the handler is already listening to this
     * event, a duplicate copy is added.
     * @param {string} events
     * @param {!function(!{type:string, target:!Object}, ...)} fn
     */
    var on = function (events, fn) {
        var eventsList = events.split(/\s+/).map(splitNs),
            i;

        if (!fn) {
            throw new Error("EventListener.on() called with no listener fn for event '" + events + "'");
        }

        // Check for deprecation warnings
        if (this._deprecatedEvents) {
            for (i = 0; i < eventsList.length; i++) {
                var deprecation = this._deprecatedEvents[eventsList[i].eventName];
                if (deprecation) {
                    var message = "Registering for deprecated event '" + eventsList[i].eventName + "'.";
                    if (typeof deprecation === "string") {
                        message += " Instead, use " + deprecation + ".";
                    }
                    console.warn(message, new Error().stack);
                }
            }
        }

        // Attach listener for each event clause
        for (i = 0; i < eventsList.length; i++) {
            var eventName = eventsList[i].eventName;
            if (!this._eventHandlers) {
                this._eventHandlers = {};
            }
            if (!this._eventHandlers[eventName]) {
                this._eventHandlers[eventName] = [];
            }
            eventsList[i].handler = fn;
            this._eventHandlers[eventName].push(eventsList[i]);

            // Check for suspicious number of listeners being added to one object-event pair
            if (this._eventHandlers[eventName].length > LEAK_WARNING_THRESHOLD) {
                console.error("Possible memory leak: " + this._eventHandlers[eventName].length + " '" + eventName + "' listeners attached to", this);
            }
        }

        return this;  // for chaining
    };

    /**
     * Removes one or more handler functions based on the space-separated 'events' list. Each item in
     * 'events' can be: bare event name, bare .namespace, or event.namespace pair. This yields a set of
     * matching handlers. If 'fn' is omitted, all these handlers are removed. If 'fn' is provided,
     * only handlers exactly equal to 'fn' are removed (there may still be >1, if duplicates were added).
     * @param {string} events
     * @param {?function(!{type:string, target:!Object}, ...)} fn
     */
    var off = function (events, fn) {
        if (!this._eventHandlers) {
            return this;
        }

        var eventsList = events.split(/\s+/).map(splitNs),
            i;

        var removeAllMatches = function (eventRec, eventName) {
            var handlerList = this._eventHandlers[eventName],
                k;
            if (!handlerList) {
                return;
            }

            // Walk backwards so it's easy to remove items
            for (k = handlerList.length - 1; k >= 0; k--) {
                // Look at ns & fn only - doRemove() has already taken care of eventName
                if (!eventRec.ns || eventRec.ns === handlerList[k].ns) {
                    var handler = handlerList[k].handler;
                    if (!fn || fn === handler || fn._eventOnceWrapper === handler) {
                        handlerList.splice(k, 1);
                    }
                }
            }
            if (!handlerList.length) {
                delete this._eventHandlers[eventName];
            }
        }.bind(this);

        var doRemove = function (eventRec) {
            if (eventRec.eventName) {
                // If arg calls out an event name, look at that handler list only
                removeAllMatches(eventRec, eventRec.eventName);
            } else {
                // If arg only gives a namespace, look at handler lists for all events
                _.forEach(this._eventHandlers, function (handlerList, eventName) {
                    removeAllMatches(eventRec, eventName);
                });
            }
        }.bind(this);

        // Detach listener for each event clause
        // Each clause may be: bare eventname, bare .namespace, full eventname.namespace
        for (i = 0; i < eventsList.length; i++) {
            doRemove(eventsList[i]);
        }

        return this;  // for chaining
    };

    /**
     * Attaches a handler so it's only called once (per event in the 'events' list).
     * @param {string} events
     * @param {?function(!{type:string, target:!Object}, ...)} fn
     */
    var one = function (events, fn) {
        // Wrap fn in a self-detaching handler; saved on the original fn so off() can detect it later
        if (!fn._eventOnceWrapper) {
            fn._eventOnceWrapper = function (event) {
                // Note: this wrapper is reused for all attachments of the same fn, so it shouldn't reference
                // anything from the outer closure other than 'fn'
                event.target.off(event.type, fn._eventOnceWrapper);
                fn.apply(this, arguments);
            };
        }
        return this.on(events, fn._eventOnceWrapper);
    };

    /**
     * Invokes all handlers for the given event (in the order they were added).
     * @param {string} eventName
     * @param {*} ... Any additional args are passed to the event handler after the event object
     */
    var trigger = function (eventName) {
        var event = { type: eventName, target: this },
            handlerList = this._eventHandlers && this._eventHandlers[eventName],
            i;

        if (!handlerList) {
            return;
        }

        // Use a clone of the list in case handlers call on()/off() while we're still in the loop
        handlerList = handlerList.slice();

        // Pass 'event' object followed by any additional args trigger() was given
        var applyArgs = Array.prototype.slice.call(arguments, 1);
        applyArgs.unshift(event);

        for (i = 0; i < handlerList.length; i++) {
            try {
                // Call one handler
                handlerList[i].handler.apply(null, applyArgs);
            } catch (err) {
                console.error("Exception in '" + eventName + "' listener on", this, String(err), err.stack);
                console.assert();  // causes dev tools to pause, just like an uncaught exception
            }
        }
    };


    /**
     * Adds the EventDispatcher APIs to the given object: on(), one(), off(), and trigger(). May also be
     * called on a prototype object - each instance will still behave independently.
     * @param {!Object} obj Object to add event-dispatch methods to
     */
    function makeEventDispatcher(obj) {
        $.extend(obj, {
            on: on,
            off: off,
            one: one,
            trigger: trigger,
            _EventDispatcher: true
        });
        // Later, on() may add _eventHandlers: Object.<string, Array.<{event:string, namespace:?string,
        //   handler:!function(!{type:string, target:!Object}, ...)}>> - map from eventName to an array
        //   of handler records
        // Later, markDeprecated() may add _deprecatedEvents: Object.<string, string|boolean> - map from
        //   eventName to deprecation warning info
    }

    /**
     * Utility for calling on() with an array of arguments to pass to event handlers (rather than a varargs
     * list). makeEventDispatcher() must have previously been called on 'dispatcher'.
     * @param {!Object} dispatcher
     * @param {string} eventName
     * @param {!Array.<*>} argsArray
     */
    function triggerWithArray(dispatcher, eventName, argsArray) {
        var triggerArgs = [eventName].concat(argsArray);
        dispatcher.trigger.apply(dispatcher, triggerArgs);
    }

    /**
     * Utility for attaching an event handler to an object that has not YET had makeEventDispatcher() called
     * on it, but will in the future. Once 'futureDispatcher' becomes a real event dispatcher, any handlers
     * attached here will be retained.
     *
     * Useful with core modules that have circular dependencies (one module initially gets an empty copy of the
     * other, with no on() API present yet). Unlike other strategies like waiting for htmlReady(), this helper
     * guarantees you won't miss any future events, regardless of how soon the other module finishes init and
     * starts calling trigger().
     *
     * @param {!Object} futureDispatcher
     * @param {string} events
     * @param {?function(!{type:string, target:!Object}, ...)} fn
     */
    function on_duringInit(futureDispatcher, events, fn) {
        on.call(futureDispatcher, events, fn);
    }

    /**
     * Mark a given event name as deprecated, such that on() will emit warnings when called with it.
     * May be called before makeEventDispatcher(). May be called on a prototype where makeEventDispatcher()
     * is called separately per instance (i.e. in the constructor). Should be called before clients have
     * a chance to start calling on().
     * @param {!Object} obj Event dispatcher object
     * @param {string} eventName Name of deprecated event
     * @param {string=} insteadStr Suggested thing to use instead
     */
    function markDeprecated(obj, eventName, insteadStr) {
        // Mark event as deprecated - on() will emit warnings when called with this event
        if (!obj._deprecatedEvents) {
            obj._deprecatedEvents = {};
        }
        obj._deprecatedEvents[eventName] = insteadStr || true;
    }


    exports.makeEventDispatcher = makeEventDispatcher;
    exports.triggerWithArray    = triggerWithArray;
    exports.on_duringInit       = on_duringInit;
    exports.markDeprecated      = markDeprecated;
});