Thursday, October 20, 2011

Memoize Backbone.js View Signatures

‹prev | My Chain | next›

Last night I implemented a version of the View Signature pattern from the forthcoming Recipes with Backbone. The basic idea is to limit the amount of individual view redrawing as much as possible by checking a signature that only changes when attributes of concern in the underlying model change.

For my Backbone.js calendar, this might mean that I only redraw the appointment when the title changes, but not when the description changes (because it does not show in month view):

One of the many nice aspects of the View Signature recipe is that no special handling is required for the DOM element. Even when switching months and detaching the appointment from the displayed DOM, the Appointment view still retains a reference to the DOM that displays the appointment. Thus, when a "change" event is seen by a view with no signature change (e.g. the title did not change), the update() method can do nothing:
      var Views = (function() {
        var Appointment = Backbone.View.extend({
          initialize: function(options) {
            // ...
            options.model.bind('change', this.update, this);
          },
          // ...
          update: function() {
            var signature = $.param({
              title: this.model.get('title')
            });

            if (this.signature === signature) return this;

            console.log("[Appointment#update] sig change: " + this.signature + " -> " + signature);

            this.signature = signature;

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

            return this;
          }
        });
But what if the view's DOM is completely wiped away (perhaps in an attempt to keep the memory usage of the application down). If the "hide" event triggers an empty() call:
      var Views = (function() {
        var Appointment = Backbone.View.extend({
          // ...
          events: {
            'hide span': 'remove'
          },
          remove: function() {
            console.log('[remove]');
            $(this.el).empty();
          }
        });
...then my moving between months suddenly results in an empty calendar:

The solution to that is to regenerate the HTML each time, but that breaks the view signature. To get around this, I break out the underscore.js memoize() function:
      var Views = (function() {
        var Appointment = Backbone.View.extend({
          initialize: function(options) {
             _.bindAll(this, 'html');
            // ...
            options.model.bind('change', this.update, this);
          },
          // ...
          update: function() {
            var signature = $.param({
              title: this.model.get('title')
            });

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

            return this;
          },
          html: function() {
            console.log('new HTML!!!');
            return this.template(this.model.toJSON());
          },
          memoized_html: function() {
            this._memoized_html || (this._memoized_html = _.memoize(this.html));

            return this._memoized_html(_.last(arguments));
          },
          // ...
        });
I continue to use the same signature (which is just this appointment title). But instead of checking the signature and then conditionally rendering or doing nothing, I now always replace the view's HTML with the result of memoized_html.

The memoized_html method does just that, it memoizes the this.html() function. If the argument to memoized_html is the same as has already been called, then memoized_html will simply return the value from the last time without the overhead of having to build the HTML from a template inside the html() method. If the argument, and here I am cleverly using the signature as the only argument, has not been seen before (e.g. it has not been called before or if the signature has changed), then the html() method is called and memoized all over again.

And it works. In the Javascript console, when the calendar is first loaded, I see the debug statement in the html() method:


After navigating through the month to force a collection cache refresh, I no longer see the html() debugging statements because my method has been memoized!

That is not a bad overall approach to View Signatures regardless of the DOM manipulation. Memoizing the view signature eliminates quite a bit of conditional code. I like.



Day #180

2 comments:

  1. View#remove shouldn't empty the div. It should remove the node from the dom entirely.

    Then if you need to render that view again you should recreated it.

    Since you're moving between months you should either:

    1) Redraw the entire month as you move around

    or

    2) Keep the month views around for each month and show/hide them, rendering as necessary. This way you can just hide Nov and render Dec. Then hide Dec and show Nov, then when they go back again, hide Nov and render Oct.

    ReplyDelete
  2. Addressed #1 in my next post: http://japhr.blogspot.com/2011/10/tidy-memoized-view-signatures-in.html

    As for #2, agreed. The problem is that I am redrawing the calendar itself outside of Backbone. I really need to fix that...

    ReplyDelete