diff --git a/backbone.js b/backbone.js index 02722ac81..ecbdecfcc 100644 --- a/backbone.js +++ b/backbone.js @@ -134,6 +134,9 @@ // Regular expression used to split event strings. var eventSplitter = /\s+/; + // A private global variable to share between listeners and listenees. + var _listening; + // Iterates over the standard `event, callback` (as well as the fancy multiple // space-separated events `"change blur", callback` and jQuery-style event // maps `{event: callback}`). @@ -174,6 +177,10 @@ if (listening) { var listeners = obj._listeners || (obj._listeners = {}); listeners[listening.id] = listening; + + // Allow the listening to use a counter, instead of tracking + // callbacks for library interop + _listening.interop = false; } return obj; @@ -186,17 +193,23 @@ if (!obj) return this; var id = obj._listenId || (obj._listenId = _.uniqueId('l')); var listeningTo = this._listeningTo || (this._listeningTo = {}); - var listening = listeningTo[id]; + var listening = _listening = listeningTo[id]; // This object is not listening to any other events on `obj` yet. // Setup the necessary references to track the listening callbacks. if (!listening) { var thisId = this._listenId || (this._listenId = _.uniqueId('l')); - listening = listeningTo[id] = {obj: obj, objId: id, id: thisId, listeningTo: listeningTo, count: 0}; + listening = _listening = listeningTo[id] = new Listening(this, obj); } - // Bind callbacks on obj, and keep track of them on listening. - internalOn(obj, name, callback, this, listening); + // Bind callbacks on obj. + var error = tryCatchOn(obj, name, callback, this); + _listening = void 0; + + if (error) throw error; + // If the target obj is not Backbone.Events, track events manually. + if (listening.interop) listening.on(name, callback); + return this; }; @@ -212,6 +225,16 @@ return events; }; + // An try-catch guarded #on function, to prevent poisoning the global + // `_listening` variable. + var tryCatchOn = function(obj, name, callback, context) { + try { + obj.on(name, callback, context); + } catch (e) { + return e; + } + }; + // Remove one or many callbacks. If `context` is null, removes all // callbacks with that function. If `callback` is null, removes all // callbacks for the event. If `name` is null, removes all bound @@ -241,8 +264,8 @@ if (!listening) break; listening.obj.off(name, callback, this); + if (listening.interop) listening.off(name, callback); } - return this; }; @@ -250,21 +273,18 @@ var offApi = function(events, name, callback, options) { if (!events) return; - var i = 0, listening; var context = options.context, listeners = options.listeners; + var i = 0, names; - // Delete all events listeners and "drop" events. - if (!name && !callback && !context) { - var ids = _.keys(listeners); - for (; i < ids.length; i++) { - listening = listeners[ids[i]]; - delete listeners[listening.id]; - delete listening.listeningTo[listening.objId]; + // Delete all event listeners and "drop" events. + if (!name && !context && !callback) { + for (names = _.keys(listeners); i < names.length; i++) { + listeners[names[i]].cleanup(); } return; } - var names = name ? [name] : _.keys(events); + names = name ? [name] : _.keys(events); for (; i < names.length; i++) { name = names[i]; var handlers = events[name]; @@ -283,11 +303,8 @@ ) { remaining.push(handler); } else { - listening = handler.listening; - if (listening && --listening.count === 0) { - delete listeners[listening.id]; - delete listening.listeningTo[listening.objId]; - } + var listening = handler.listening; + if (listening) listening.off(name, callback); } } @@ -298,7 +315,8 @@ delete events[name]; } } - return events; + + if (_.size(events)) return events; }; // Bind an event to only be triggered a single time. After the first time @@ -313,7 +331,7 @@ }; // Inversion-of-control versions of `once`. - Events.listenToOnce = function(obj, name, callback) { + Events.listenToOnce = function(obj, name, callback) { // Map the event into a `{event: once}` object. var events = eventsApi(onceMap, {}, name, callback, _.bind(this.stopListening, this, obj)); return this.listenTo(obj, events); @@ -348,7 +366,7 @@ }; // Handles triggering the appropriate event callbacks. - var triggerApi = function(objEvents, name, callback, args) { + var triggerApi = function(objEvents, name, cb, args) { if (objEvents) { var events = objEvents[name]; var allEvents = objEvents.all; @@ -373,6 +391,44 @@ } }; + // A listening class that tracks and cleans up memory bindings + // when all callbacks have been offed. + var Listening = function(listener, obj) { + this.id = listener._listenId; + this.listener = listener; + this.obj = obj; + this.interop = true; + this.count = 0; + this._events = void 0; + }; + + Listening.prototype.on = Events.on; + + // Offs a callback (or several). + // Uses an optimized counter if the listenee uses Backbone.Events. + // Otherwise, falls back to manual tracking to support events + // library interop. + Listening.prototype.off = function(name, callback) { + var cleanup; + if (this.interop) { + this._events = eventsApi(offApi, this._events, name, callback, { + context: void 0, + listeners: void 0 + }); + cleanup = !this._events; + } else { + this.count--; + cleanup = this.count === 0; + } + if (cleanup) this.cleanup(); + }; + + // Cleans up memory bindings between the listener and the listenee. + Listening.prototype.cleanup = function() { + delete this.listener._listeningTo[this.obj._listenId]; + if (!this.interop) delete this.obj._listeners[this.id]; + }; + // Aliases for backwards compatibility. Events.bind = Events.on; Events.unbind = Events.off; diff --git a/test/events.js b/test/events.js index 544b39a19..d7a115dd5 100644 --- a/test/events.js +++ b/test/events.js @@ -703,4 +703,41 @@ two.trigger('y', 2); }); + test("#3611 - listenTo is compatible with non-Backbone event libraries", 1, function() { + var obj = _.extend({}, Backbone.Events); + var other = { + events: {}, + on: function(name, callback) { + this.events[name] = callback; + }, + trigger: function(name) { + this.events[name](); + } + }; + + obj.listenTo(other, 'test', function() { ok(true); }); + other.trigger('test'); + }); + + test("#3611 - stopListening is compatible with non-Backbone event libraries", 1, function() { + var obj = _.extend({}, Backbone.Events); + var other = { + events: {}, + on: function(name, callback) { + this.events[name] = callback; + }, + off: function() { + this.events = {}; + }, + trigger: function(name) { + var fn = this.events[name]; + if (fn) fn(); + } + }; + + obj.listenTo(other, 'test', function() { ok(false); }); + obj.stopListening(other); + other.trigger('test'); + equal(_.size(obj._listeningTo), 0); + }); })();