Friday, October 21, 2011

Tidy Memoized View Signatures in Backbone.js

‹prev | My Chain | next›

Up tonight, I hope to tidy up the Memoized View Signature pattern for Backbone.js that I worked through last night. The idea behind View Signatures is to only draw or build views when the underlying data has changed. More specifically, the views should only update the DOM if attributes being displayed are updated—no need to update a title-only view if the updated timestamp has changed.

I am still using my Backbone calendar to explore this concept:

Working down from the top, when a new month is displayed, a "clear" event is triggered on all calendar appointments:
      var Routes = Backbone.Router.extend({
        routes: {
          // ...
          "month/:date": "setMonth"
        },
        // ...
        setMonth: function(date) {
          console.log("[setMonth] %s", date);

          $('.appointment').trigger('clear');
          draw_calendar(date);
          appointments.setDate(date);
        }
      });
Down in the view, I listen for the "clear" event, removing the element when received:

        var Appointment = Backbone.View.extend({
          // ...
          events: {
            // ...
            'clear span': 'remove'
          },
          // ...
          remove: function() {
            $(this.el).empty();
          }
        });
As Nick Gauthier (my Recipes with Backbone co-author) pointed out in comments on last night's post, empty() is probably not the right choice here. When switching months, I do not want to simply empty last month's elements, I want to remove them entirely.

The most obvious solution would be to call remove() on the element. In fact, this is what Backbone does by default:
    // Remove this view from the DOM. Note that the view isn't present in the
    // DOM by default, so calling this method may be a no-op.
    remove : function() {
      $(this.el).remove();
      return this;
    },
The problem with this solution is that, even though the element is removed from the DOM, its is not gone. The view's el attribute still references the DOM object. To illustrate this, I drop into Chrome's Javascript console. In there, I assign one of my appointments to a variable. I then manually remove() all appointments. The DOM element is still in memory by virtue of that local variable (even though it is not longer attached to the page's DOM):

Now, in this case, this is not a huge deal—the appointment itself is not consuming tons of resources. But what if it was? To prevent those no longer needed resources from lingering, I do want to empty the element of content (jQuery also removes associated events) and remove it from the page's DOM:
          remove: function() {
            $(this.el).remove().html('');
            return this;
          }
Oh, and because I was looking through the Backbone code at the remove() method, I was reminded that my remove() method also needs to return this to properly support chaining.

That covers removing the elements. To add the appointment back when the user goes back a month, I respond to a custom "cached" event (from my collection cache work earlier this week):
        var Appointment = Backbone.View.extend({
          initialize: function(options) {
            _.bindAll(this, 'html');
            // bind to model events here
            options.model.bind('cached', this.render, this);
            options.model.bind('change', this.update, this);
          },
          // ....
        });
The bindAll is necessary for the memoize to work (as we'll see in a second).

The render() method calls the update method (in addition to some other DOM manipulation unimportant here):
          render: function() {
            this.update();
            // ...
          }
So, all of the important work for the memoized View Signature is done in that update() method:

          update: function() {
            var signature = $.param({
              title: this.model.get('title')
            });

            $(this.el).html(this.memoized_html(signature));

            return this;
          }
First, I calculate a view "signature" that will only change when the attributes that I care about in the model change. In this case, I only want the signature to change when the title has been updated. I pass that signature to the memoized_html() method. That method should generate new HTML if the signature is different from one that has already been drawn (or if it has never been drawn). Otherwise, it should re-use the previously used HTML.

To achieve that, as the name suggests, I make use of the underscore.js memoize() function:

          html: function() {
            return this.template(this.model.toJSON());
          },
          memoized_html: function() {
            this._memoized_html || (this._memoized_html = _.memoize(this.html));

            return this._memoized_html(_.last(arguments));
          },
          // ...
        });
The vanilla html() method does a normal template build. The memoize_html method remembers the result of that build, storing it by the argument passed—the signature. Thus, if the signature changes (by virtue of a model's title changing), memoize won't find the result internally and will re-invoke html().

As mentioned earlier, the bindAll for the html is necessary because of the memoization done here. Without it, memoized calls of this.html would lose the context of the current object. Which would be bad.

The complete implementation of the View Signature bit is then:
        var Appointment = Backbone.View.extend({
          initialize: function(options) {
            _.bindAll(this, 'html');
            // bind to model events here
            options.model.bind('cached', this.render, this);
            options.model.bind('change', this.update, this);
          },
          events: {
            // ...
            'clear span': 'remove'
          },
          render: function() {
            this.update();
            // ...
          },
          update: function() {
            var signature = $.param({
              title: this.model.get('title')
            });

            $(this.el).html(this.memoized_html(signature));

            return this;
          },
          html: function() {
            return this.template(this.model.toJSON());
          },
          memoized_html: function() {
            this._memoized_html || (this._memoized_html = _.memoize(this.html));

            return this._memoized_html(_.last(arguments));
          },
          remove: function() {
            $(this.el).remove().empty();
            return this;
          }
        });
I am satisfied with that implementation. At least satisfied enough to start adding it to Recipes with Backbone. It still may change so you should still buy it!

Up tomorrow, I am going to take a quite tour of Underscore.js and then, who knows? There is always fun to be had with Backbone.js.

Day #181

2 comments:

  1. Why _.last arguments when there's just a single argument? Why not just write it memoized_html: function(sig)? Why do you have to $.param the title when it's just a string?

    ReplyDelete
    Replies
    1. Good points in both cases.

      The _.last is completely unnecessary here since memoized_html only takes a single argument. I think I added that the night previous when I was fiddling with the implementation. It is a complete oversight on my part not to have removed it. It should just be a normal argument to the method now.

      I don't have to $.param the title in this case. In the book, we don't really recommend doing this with a single parameter, in which case the $.param() thing makes more sense.

      Delete