diff --git a/backbone.js b/backbone.js index 1dfbd9c50..69238da66 100644 --- a/backbone.js +++ b/backbone.js @@ -87,6 +87,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}`), reducing them by manipulating `memo`. @@ -96,7 +99,7 @@ var i = 0, names; if (name && typeof name === 'object') { // Handle event maps. - for (names = _.keys(name); i < names.length ; i++) { + for (names = _.keys(name); i < names.length; i++) { memo = iteratee(memo, names[i], name[names[i]], opts); } } else if (name && eventSplitter.test(name)) { @@ -113,43 +116,46 @@ // Bind an event to a `callback` function. Passing `"all"` will bind // the callback to all events fired. Events.on = function(name, callback, context) { - return internalOn(this, name, callback, context); - }; - - // An internal use `on` function, used to guard the `listening` argument from - // the public API. - var internalOn = function(obj, name, callback, context, listening) { - obj._events = eventsApi(onApi, obj._events || {}, name, callback, { - context: context, - ctx: obj, - listening: listening + this._events = eventsApi(onApi, this._events || {}, name, callback, { + context: context, + ctx: this, + listening: _listening }); - if (listening) { - var listeners = obj._listeners || (obj._listeners = {}); - listeners[listening.id] = listening; + if (_listening) { + var listeners = this._listeners || (this._listeners = {}); + listeners[_listening.id] = _listening; + // Allow the listening to use a counter, instead of tracking + // callbacks for library interop + _listening.backbone = true; } - return obj; + return this; }; // Inversion-of-control versions of `on`. Tell *this* object to listen to // an event in another object... keeping track of what it's listening to. - Events.listenTo = function(obj, name, callback) { + Events.listenTo = function(obj, name, callback) { 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}; + this._listenId || (this._listenId = _.uniqueId('l')); + 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.backbone) listening.on(name, callback); + return this; }; @@ -165,6 +171,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 @@ -172,9 +188,10 @@ Events.off = function(name, callback, context) { if (!this._events) return this; this._events = eventsApi(offApi, this._events, name, callback, { - context: context, - listeners: this._listeners + context: context, + listeners: this._listeners }); + return this; }; @@ -185,7 +202,6 @@ if (!listeningTo) return this; var ids = obj ? [obj._listenId] : _.keys(listeningTo); - for (var i = 0; i < ids.length; i++) { var listening = listeningTo[ids[i]]; @@ -194,9 +210,8 @@ if (!listening) break; listening.obj.off(name, callback, this); + if (!listening.backbone) listening.off(name, callback); } - if (_.isEmpty(listeningTo)) this._listeningTo = void 0; - return this; }; @@ -205,16 +220,13 @@ // No events to consider. if (!events) return; - var i = 0, length, 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; } @@ -227,7 +239,7 @@ // Bail out if there are no events stored. if (!handlers) break; - // Replace events if there are any remaining. Otherwise, clean up. + // Find any remaining events. var remaining = []; for (var j = 0; j < handlers.length; j++) { var handler = handlers[j]; @@ -238,21 +250,19 @@ ) { 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); } } - // Update tail event if the list has any events. Otherwise, clean up. + // Replace events if there are any remaining. Otherwise, clean up. if (remaining.length) { events[name] = remaining; } else { delete events[name]; } } + if (_.size(events)) return events; }; @@ -327,6 +337,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.backbone = false; + this.count = 0; + this._events = {}; + }; + + 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.backbone) { + this.count--; + cleanup = this.count === 0; + } else { + this._events = eventsApi(offApi, this._events, name, callback, { + context: void 0, + listeners: void 0 + }); + cleanup = !this._events; + } + 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.backbone) delete this.obj._listeners[this.id]; + }; + // Proxy Underscore methods to a Backbone class' prototype using a // particular attribute as the data argument var addMethod = function(length, method, attribute) {