Google Closure Library の goog.events.EventHandler & goog.events.EventTarget のシンプル版を作ってみた

かれこれ1年くらい Google Closure Library を仕事でガッツリ使ってます。
それまでは jQuery でよくね?と思ってたのですが、もはや Closure Library なしでブラウザの大規模UI作るなんて考えられない!というくらい馴染んでしまいました。
ソースも綺麗だし空いたときにソース読んでるだけでも勉強になります。さすが Google さん。

中でも goog.events.EventHandler & goog.events.EventTarget は DOM に依存せずオブジェクト間で簡単に通信ができて便利。
自宅で軽いサンプル作るときにも使いたいな、でも軽く使うには Closure Library はめんどくさすぎ。

ということで仕組みだけそのままにシンプルなものを作ってみました。

// Base object.
var util = {
    events: {}
};

/**
 * @param {*} opt_scope
 * @constructor
 */
util.events.EventHandler = function(opt_scope) {
    this.scope_ = opt_scope || null;
};

/**
 * @lends util.events.EventHandler
 */
util.events.EventHandler.prototype = {
    /**
     * @type {*}
     * @private
     */
    scope_: null,

    /**
     * @type {number}
     * @private
     */
    uidCounter_: 0,

    /**
     * Name for unique ID property.
     * @type {string}
     * @private
     */
    UID_PROPERTY_: '_uid_' + Math.floor(Math.random() * 2147483648).toString(36),

    /**
     * @return {string}
     */
    getUid: function() {
        return this.UID_PROPERTY_ + (this.uidCounter_++);
    },

    /**
     * @type {*} src
     * @type {string} type
     * @type {Function} listener
     * @type {!*} opt_scope
     */
    listen: function(src, type, listener, opt_scope) {
        if (!src.uid_) {
            src.uid_ = this.getUid();
        }
        var scope = opt_scope || (this.scope_ || listener);
        util.events.EventProxy.add(src, type, listener, scope);
    },

    /**
     * @type {*} src
     * @type {string} type
     * @type {Function} listener
     * @type {!*} opt_scope
     */
    unlisten: function(src, type, listener, opt_scope) {
        var scope = opt_scope || (this.scope_ || listener);
        return util.events.EventProxy.remove(src, type, listener, scope);
    }
};

/**
 * @constructor
 */
util.events.EventTarget = function() {
};

/**
 * @lends util.events.EventTarget
 */
util.events.EventTarget.prototype = {
    /**
     * @param {string} type
     * @param {*} data
     */
    dispatchEvent: function(type, data) {
        util.events.EventProxy.fireListener_(this, type, data);
    }
};

/**
 * @static
 */
util.events.EventProxy = {
    /**
     * @type {Object}
     * @private
     */
    listenerTree_: {},

    /**
     * @param {*} src
     * @param {string} type
     * @param {Function} listener
     * @param {*} scope
     * @return {boolean}
     */
    add: function(src, type, listener, scope) {
        var uid = src.uid_;
        if (uid === undefined) {
            return false;
        }

        if (!this.listenerTree_[uid]) {
            this.listenerTree_[uid] = {};
        }
        if (!this.listenerTree_[uid][type]) {
            this.listenerTree_[uid][type] = [];
        }
        this.listenerTree_[uid][type].push([listener, scope]);
        return true;
    },

    /**
     * @param {*} src
     * @param {string} type
     * @param {Function} listener
     * @param {*} scope
     * @return {boolean}
     */
    remove: function(src, type, listener, scope) {
        var result = false,
            uid = src.uid_;
        if (uid === undefined) {
            return result;
        }

        var listeners = this.listenerTree_[uid][type];
        for (var i = 0, len = listeners.length; i < len; i++) {
            var l = listeners[i];
            if (l[0] === listener && l[1] === scope) {
                this.listenerTree_[uid][type].splice(i, 1);
                result = true;
                break;
            }
        }
        return result;
    },

    /**
     * @param {*} src
     * @param {string} type
     * @return {Array<Function, *>}
     */
    get: function(src, type) {
        var uid = src.uid_;
        return this.listenerTree_[uid][type];
    },

    /**
     * @param {*} src
     * @param {string} type
     * @param {*} arg
     * @private
     */
    fireListener_: function(src, type, arg) {
        var listeners = this.get(src, type);
        for (var i = 0, len = listeners.length; i < len; i++) {
            var listener = listeners[i];
            listener[0].call(listener[1], arg);
        }
    }
};

addEventListener のオブジェクト登録版といった感じです。DOMイベントに依存してるわけではないので、イベント発火側は自由にイベント名を付けられます。
イベント受け取り/発火オブジェクトは、EventProxy クラスを介してやりとり。

もちろん Closure Library ではいろいろ他にもごにょごにょしてるけど、実験用ならこれくらいでいいかなー。

使い方は Closure Library と同じです。 簡単な QUnitテスト。

/**
 * @param {Function} childCtor
 * @param {Function} parentCtor
 */
function inherits(childCtor, parentCtor) {
    function tempCtor() {};
    tempCtor.prototype = parentCtor.prototype;
    childCtor.superClass_ = parentCtor.prototype;
    childCtor.prototype = new tempCtor();
    childCtor.prototype.constructor = childCtor;
}

var TargetClass = function() {
    util.events.EventTarget.call(this);
};
inherits(TargetClass, util.events.EventTarget);

var ListenerClass = function() {
    this.eh_ = new util.events.EventHandler();
};
ListenerClass.prototype = {
    count_: 0,
    addEvent: function(src, type, listener, opt_scope) {
        this.eh_.listen(src, type, listener, opt_scope);
    },
    removeEvent: function(src, type, listener, opt_scope) {
        this.eh_.unlisten(src, type, listener, opt_scope);
    },
    increment: function(args) {
        this.count_++;
    },
    getCount: function() {
        return this.count_;
    }
};

//
var target = new TargetClass();
var listener = new ListenerClass();

module("EventHandler/Target test", {
    setup: function() {
        listener.addEvent(target, 'foo', listener.increment, listener);
    },
    teardown: function() {
        listener.removeEvent(target, 'foo', listener.increment, listener);
    }
});

asyncTest("dispatch test", function() {
    expect(2);
    ok(true);
    target.dispatchEvent('foo');
    setTimeout(function() {
        var count = listener.getCount();
        equals(count, 1, "number");
        start();
    }, 13);
});

asyncTest("dispatched count test", 2, function() {
    expect(2);
    ok(true);
    var count = listener.getCount();
    equals(count, 1, "number");
    start();
});

DOMイベントに依存せずリスナを設定/発火できるので、DOMイベント張りまくって遅くなったりしたらこういう仕組みもありですね。