Sophomore Dev

Extending Backbone views to configure non-DOM events

Quick post today. One of the things I like about Backbone views is how you configure events. It’s very easy and avoids a lot of boilerplate code. For example, if I have a table and want to listen for clicks on the cells, all I need to do is:

var MyView = Backbone.View.extend({
    events: {
        'click td': '_onCellClick'
    },
 
    _onCellClick: function (e) {
        // do stuff...
    }
});

There are lots of nice things about the way Backbone view events work. However, one limitation is they only apply to DOM events. Often I’ll use Backbone events to abstract away what should happen between views or models. Compare the two examples below. In the first when I update the data, everything is tightly coupled together. In the second, they’re decoupled:

var TightlyCoupledView = Backbone.View.extend({
    updateData: function (data) {
        this.model.set('data', data);
        this.updateGraph();
    },
 
    updateGraph: function () {}
});
var LooselyCoupledView = Backbone.View.extend({
    initialize: function (options) {
        this.model.on('change:data', this.updateGraph, this);
    },
 
    updateData: function (data) {
        this.model.set('data', data);
    },
 
    updateGraph: function () {}
});

It’s a bit more code, but what’s nice is that if I need to add more things to do when the data changes or have more than one point where I update data, it’s not hard to do:

var MoreComplicatedView = Backbone.View.extend({
    initialize: function (options) {
        this.model.on('change:data', this.updateGraph, this);
        this.model.on('change:data', this.updateTable, this);
    },
 
    getDataFromAnotherObject: function (obj) {
        this.model.set('data', obj.getData());
    },
 
    refreshDataFromServer: function (data) {
        var self = this;
 
        $.get('/data/latest', function (data) {
            self.model.set('data', data);
        });
    },
 
    updateGraph: function () {},
 
    updateTable: function () {}
});

That’s all good, but you can see what ends up happening. I have a lot of boiler plate hooking things together — the same way you would with DOM events if it weren’t for the way Backbone allows you to configure them. So what I do is an extended view that all my views inherit from that gives them this functionality:

// Cached regex to split keys for `delegate`.
var eventSplitter = /^(\S+)\s*(.*)$/;
 
var ExtendedView = Backbone.View.extend({
    delegateViewEvents : function (events) {
        if (!(events || (events = this.viewEvents))) return;
 
        if (_.isFunction(events)) events = events.call(this);
 
        this.undelegateViewEvents();
 
        for (var key in events) {
            var method = this[events[key]];
 
            if (!method) {
                throw new Error('Event "' + events[key] + '" does not exist');
            }
 
            var match = key.match(eventSplitter);
            var eventName = match[1], selector = match[2];
 
            if (selector === '') {
                this.bind(eventName, method, this);
            } else {
                this[selector].bind(eventName, method, this);
            }
        }
    },
 
    // Clears all callbacks previously bound to the view with `delegateEvents`.
    undelegateViewEvents: function(eventName) {
        this.unbind(eventName);
    }
});

It looks very similar to how Backbone sets up and manages DOM events. And here’s how it’s used in practice:

var MoreComplicatedView = Backbone.View.extend({
    viewEvents: {
        'change:data model': '_onModelDataChange'
    },
 
    initialize: function (options) {
        this.delegateViewEvents();
    },
 
    updateGraph: function () {},
 
    updateTable: function () {},
 
    _onModelDataChange: function () {
        this.updateGraph();
        this.updateTable();
    }
});

It’s simple and mirrors the way you’re used to doing things with DOM events. What do you think? How do you handle this issue?

  • Jason

    This got me thinking about how I could better structure the code in our largest Backbone app. The app is an analytics dashboard that has been architected to be read-only. There is a ton of repeative code in it, mostly handling changing of settings, something like this:
    0. User loads page initially
    1. Page renders in default state
    2. User selects different settings (different time frame, add/remove data source, etc)
    3. Page refreshes by reloading the entire view from the Backbone.js Router

    There are a half-dozed pages that allow follow this same pattern.

    Based on what you did above, I am now thinking that it may be helpful to extract a separate class that just handles the view events. This is almost a rails style observer. So the could would end up looking something like (caveat: the following is totally untested): https://gist.github.com/795310c4c40791001ea0

    This ends up almost adding a 4th leg to the MVC stole, but I think it would be very useful for reusability in our app. I may give this a try next time I’m trying to pay down the technical debt :)

    • bialecki

      The situation you describe is definitely one I’m in a lot. Like your approach and would be curious to see a more fleshed out example.

  • Floyd May

    Have you considered Haymaker.js? Non-DOM events are a core concept there.

    • bialecki

      Interesting, I’ll have to look at.

  • Daniel Björkander

    We actually use a similar pattern with our app.
    We used to have problems with memory leaks and bugs due to events and subviews left alive after a view’s lifetime (us forgetting to unbind events and removing subviews).

    Now all events have to be bound using our custom attributes, so that when .remove() is called on the view, no loose ends are left hanging. This has solved most of our problems with memory leaks.

    An example of how we are using it:
    https://gist.github.com/2949517

    • bialecki

      Looks very similar. Can you give an example where you have functions bound to those events you’re emitting?

      • Daniel Björkander

        Yeah, the binding of model and collection events are kind of identical, except that we also extend Backbone.View.remove to ensure that all of our events and subviews are cleared. Since we are sharing models across several views with different lifespan, we really have to ensure that there are no loose ends.

        Functions bound to messengerEvents work kind of like you expect, if you call Messenger.emit(‘page.new’, ‘pagename’), the bound function gets called with ‘pagename’ as the first argument.

        We both basically came to the same conclusion :-) I really like the approach. Since most of the behaviour of a complex view is based on input from the dom or models/collections/pubsub, you can get a pretty good picture of how the view works by just looking at the options defined.

      • Daniel Björkander

        (Messenger is our global pub/sub object :-)

      • bialecki

        “You can get a pretty good picture of how the view works by just looking at the options defined.”

        Exactly. That’s my favorite part.

      • Daniel Björkander

        One nice part in our implementation is that we store the definition of the binding, so that we can unbind only the events that our view has bound, so that we don’t mess with events bound by other views on the model (if the model is shared across several views).

        https://gist.github.com/2951001