Last Updated: September 09, 2019
·
9.52K
· andrewjhart

Killing Backbone Zombies in Single Page Apps

Thought you killed all your zombie views in your backbone application? So did I, until I noticed a keyup event was triggering multiple renders, and that the number of draws went up incrementally according to the number of times I navigated away from and back to the search page. I had already prototyped a close method onto Backbone.View ( as per great work done here http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/ ) and was unbinding and removing the views to no avail! Turns out that view encapsulation can lead to zombies everywhere.

After thorough investigation I found 2 serious issues:

  1. I realized that my collection view was never being closed! Even though I was only instantiating the search page once and referencing it from the router, the search page view encapsulated another view that rendered the collection. It looks so simple now but this is a perfect example of how memory leaks can sneak up you.

  2. You have call an unbind on your collection not just your view. This one tripped me up for a minute. I suppose that makes closing a 3 step process: unbind view events, remove the view from DOM, and unbind the collection (or model)... Test it.. simply calling unbind on the view and remove wont fix the issue. Your application will continue to use more and more memory triggering events on the collection multiple times!

Example before code:

// add default close method for views
Backbone.View.prototype.close = function () {
    console.log('Unbinding events for ' + this.cid);
    this.remove();
    this.unbind();

    if (this.onClose) {
        this.onClose();
    }
};

// page view for search page
window.SearchPage = Backbone.View.extend({
    render: function() {
        // render search template
        this.$el.html(this.template());

        // create list view to render collection
        this.listview = new MessageListView({ 
            el: $('ul', this.el), 
            collection: this.collection 
        });

        this.listview.render();

        return this;
    },

    events: {
        'keyup .search-query': 'search',
    },

    search: function (e) {
        var queryVal = $('.search-query').val();

        // actual filter logic is in the collection
        this.collection.find(queryVal);
    },
});

// ul view - wrapper for list items view
window.MessageListView = Backbone.View.extend({

    initialize: function () {
        this.collection.bind('reset', this.render, this);   
        this.collection.bind('change', this.render, this)
        this.collection.bind('add', this.render, this);     
        this.collection.bind('remove', this.render, this);
    },

    render: function () {
        var self = this;
        this.$el.empty();

        // instantiate & pass model to list item view
        this.collection.each(function (message) {
            self.$el.append(new MessageListItemView({ 
                model: message }).render().el);
        }, this);

        return this;
    }
});

The issue above is that each time we render the search page - from the router's default list method in this case - it creates a new listview object... So close it you say?? Simple enough...

// keep a reference to it and close the old
        if (!this.listview) {
            this.listview = new MessageListView({ el: $('ul', this.el), model: this.model, collection: this.collection });
        }
        else {
            this.listview.close();
            this.listview = new MessageListView({ el: $('ul', this.el), model: this.model, collection: this.collection });
        }

        this.listview.render();

At first glance I thought the above code would fix my problem.. However, if you follow the trail you will notice that we still have lingering events bound to our collection even though it appears to have been removed from the DOM... The fix?

// inside your sub-view or any view that is binding
// to a model or collection
    onClose: function () {
        this.collection.unbind();
    }

You could just put a line like this.collection.unbind() in the prototyped close, however since not all views have reference to a collection you still need to check that it exists or you can do it like above and perhaps do additional cleanup or unbind from specific events like:

onClose: function() {
    this.collection.unbind('add', this.render);
}

So if you are logging your objects and notice that a collection is being rendered more than once remember to call unbind on the collection too!

Also, I decided to restructure the application views altogether differently to and merge the search and collection list views into one View... Seems cleaner, minimizing the amount of views instantiating sub-views and keeping references to them. One caveat to moving the logic into one view is that you must still have a separate function for rendering the collection and the search template or you will end up redrawing the page every time you type a key into your search box... Filtering on the collection triggers a re-render so separating the logic something like this:

// Search Page View
initialize: function() {
    this.template = _.template($('#search').html());

    this.collection.bind('reset', this.update, this);
    this.collection.bind('change', this.update, this);
    this.collection.bind('add', this.update, this);
    this.collection.bind('remove', this.update, this);
},

render: function () {
    this.$el.empty();

    this.$el.html(this.template(this.model.toJSON()));

    this.update();

    return this;
},

update: function() {
    $('#myList', this.el).empty();

    this.collection.each(function (message) {
        $('#myList', this.el).append(new ListItem({ model: message }).render().el);
    }, this);

    return this;
},

events: {
    'keyup .search-query': 'search',
},

search: function (e) {
    var queryVal = $('.search-query').val();

    // filter in collection
    this.collection.find(queryVal);
}

Do it this way means we only have the sub-view of listItem to maintain within the Search View, instead of Search View -> List View -> List Item View.. Furthermore, by using a different templating engine such as mustache, you can pass the entire collection to the template and iterate over it there.. Not sure that passing the collection to a template engine is faster than iterating over it in javascript but it might help you keep your sanity.

1 Response
Add your response

I'm aiming to switch away from using CanJS to Backbone on my project and the zombie issue is something that bothered me about Backbone. CanJS doesn't have this problem. The reason for it is that it ties into the JQuery $.cleanData method, which gets fired after an element is removed from the DOM (using html(), remove(), etc). I would look into utilizing the same type of thing in BackBone (which I plan to once I get a chance to convert my code over to BackBone).

Taken from CanJS codebase

    // Memory safe destruction.
var oldClean = $.cleanData;

$.cleanData = function( elems ) {
    $.each( elems, function( i, elem ) {
        if ( elem ) {
            can.trigger(elem,"destroyed",[],false);
        }
    });
    oldClean(elems);
};
over 1 year ago ·