Friday, October 14, 2011

Backbone Caching: refactored

‹prev | My Chain | next›

Last night I was able to adapt my Backbone.js caching solution to one based on subcollections. The subcollections are responsible for fetching all of the appointments in a given month. Upon success, the models in the subcollection are added to the main collection, which triggered "cached" events. Views listen for this event so that they know to render the appointment on the calendar:
And this actually seemed to work. It might even be a good overall approach. The implementation leaves something to be desired. In a word: "long". In four words: "longer than my arm".

The fetch() method itself is where the trouble begins:
      var Collections = (function() {
        var Appointments = Backbone.Collection.extend({
          model: Models.Appointment,
          url: '/appointments',
          cached: {},
          initialize: function(models, options) {
            options || (options = {});
            this.date = options.date;
          },
          fetch: function(options) {
            options || (options = {});
            var date = options.date || this.date;

            if (this.cached[date]) {
              return this.triggerCacheHits();
            }

            var month =
              this.cached[date] =
              new MonthAppointments(undefined, {date: date});

            var that = this;
            month.fetch({
              success: function(collection, resp) {
                that.add(collection.models);
                if (!options.prefetch) {
                  that.fetch({
                    date: Helpers.previousMonth(date),
                    prefetch: true
                  });
                }
              }
            });
          },
          // ...
        });
Tonight, I hope to refactor into something approaching decent.

I start from the top and work my way down. Thus the fetch() method is the first beasty to review. All that fetch should do is ensure that a date (YYYY-MM) is set for fetching, and try to fetch from the cache:
          fetch: function(options) {
            options || (options = {});
            options.date || (options.date = this.date);
            options.success ||
              (options.success = _.bind(this._prefetch, this, options.date))

            this.fetchFromCache(options);
          }
If fetch is successful—either from cache or from the server—then the code should initiate a pre-fetch of the dates immediately adjoining the requested month:
          _prefetch: function(date) {
            this.fetch({
              date: Helpers.previousMonth(date),
              success: function() {console.log("NOP -- prefetch")}
            });

            this.fetch({
              date: Helpers.nextMonth(date),
              success: function() {console.log("NOP -- prefetch")}
            });
          }
The pre-fetch should not go on indefinitely. I only want the adjoining months. To prevent subsequent pre-fetches, I supply a no-op succcess() callback that will fire instead of the prefetch.

The fetch() method invokes fetchFromCache(). In fetchFromCache(), I simply invoke the success() callback if the requested date is already cached. If the date is not cached, then I fetch the data from the server:
          fetchFromCache: function(options) {
            var success = options.success;
            options.success = _.bind(function() {
              this.triggerCacheHits(options.date)
              success();
            }, this);

            if (this.cached[options.date]) {
              options.success();
            }
            else {
              this.fetchFromServer(options);
            }
          }
In either case, I want to trigger a "cached" event. So I add a call to triggerCacheHits() inside the success callback chain. I adopt the chaining strategy used inside backbone itself, which saves the options.success callback passed in (the prefetch in this case) as a local variable. Then I assign a new on-success callback that includes a call to the original options.success. But first, I trigger a successful "cached" event for anyone and anything that cares.

Continuing to work my way down, next in line is fetchFromServer(). To do that, I create a MonthAppointments sub-collection to retrieve all of the appointments from the target month and tell it to fetch:
          fetchFromServer: function(options) {
            var subcollection =
              this.cached[options.date] =
              (new MonthAppointments());

            var success = options.success;
            options.success = _.bind(function(collection, resp) {
              this.addFromSubCollection(collection);
              success();
            }, this);

            subcollection.fetch(options);
          }
At this point, the options.success will trigger "cached" events and prefetch. Here I add yet another on-success action: adding each model from the subcollection to the current Appointments collection.

The addFromSubCollection() is so small that I might be better off just putting it directly inside fetchFromServer():
          addFromSubCollection: function(subcollection) {
            this.add(subcollection.models);
          }
For now, I leave it separate—if for no other reason than to keep intent perfectly clear.

The best part of this solution is the MonthAppointments sub-collection, which belies none of the caching complexity that I am trying to implement. It is a very simple, very small collection:
        var MonthAppointments = Backbone.Collection.extend({
          model: Models.Appointment,
          url: '/appointments',
          fetch: function(options) {
            options || (options = {});

            // only fetch appointments from this month
            var data = (options.data || {});
            options.data = {date: options.date};

            return Backbone.Collection.prototype.fetch.call(this, options);
          },
          parse: function(response) { /* CouchDB record parsing */}
        });
How does that little sub-collection add to the cache? Trigger all the events? Pre-fetch adjacent months? It doesn't. It simply passes options to Backbone.Collection.fetch()—the same options that contains my chain of success callbacks. The fetch() method calls options.success when it successfully fetches the data from the server. This, in turn, adds the retrieved models to the parent collection, triggers the "cached" event, and prefetches the adjoining months' data.

And that actually works. If I load up the calendar, I get the same view. Checking Chrome's Javascript console, I see the regular fetch, plus the two desired pre-fetches into cache:

I am not entirely sold on this approach, but it is composed of smaller pieces that yesterday. I am mostly concerned that I am doing too much binding for my own good. I will pick this back up tomorrow to see if I can find any holes in this approach.

Day #174

No comments:

Post a Comment