Skip to content

Commit

Permalink
Merge pull request #4131 from jbboehr/private-listening-public-on
Browse files Browse the repository at this point in the history
Use the public `on` method when listening
  • Loading branch information
megawac committed Apr 9, 2017
2 parents bd50e2e + e4939e4 commit 539c9af
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 34 deletions.
120 changes: 86 additions & 34 deletions backbone.js
Expand Up @@ -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}`).
Expand All @@ -160,23 +163,21 @@
// 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);
};

// Guard the `listening` argument from the public API.
var internalOn = function(obj, name, callback, context, listening) {
obj._events = eventsApi(onApi, obj._events || {}, name, callback, {
this._events = eventsApi(onApi, this._events || {}, name, callback, {
context: context,
ctx: obj,
listening: listening
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.interop = false;
}

return obj;
return this;
};

// Inversion-of-control versions of `on`. Tell *this* object to listen to
Expand All @@ -186,17 +187,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};
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.interop) listening.on(name, callback);

return this;
};

Expand All @@ -212,6 +219,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
Expand All @@ -222,6 +239,7 @@
context: context,
listeners: this._listeners
});

return this;
};

Expand All @@ -232,7 +250,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]];

Expand All @@ -241,7 +258,9 @@
if (!listening) break;

listening.obj.off(name, callback, this);
if (listening.interop) listening.off(name, callback);
}
if (_.isEmpty(listeningTo)) this._listeningTo = void 0;

return this;
};
Expand All @@ -250,29 +269,26 @@
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];

// 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];
Expand All @@ -283,21 +299,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];
}
}

return events;
};

Expand Down Expand Up @@ -373,6 +387,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;
Expand Down
37 changes: 37 additions & 0 deletions test/events.js
Expand Up @@ -703,4 +703,41 @@
two.trigger('y', 2);
});

QUnit.test('#3611 - listenTo is compatible with non-Backbone event libraries', function(assert) {
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() { assert.ok(true); });
other.trigger('test');
});

QUnit.test('#3611 - stopListening is compatible with non-Backbone event libraries', function(assert) {
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() { assert.ok(false); });
obj.stopListening(other);
other.trigger('test');
assert.equal(_.size(obj._listeningTo), 0);
});
})(QUnit);

0 comments on commit 539c9af

Please sign in to comment.