Tuesday, October 11, 2011

A Slightly Less Buggy Poor Man's Backbone.js Collection Caching

‹prev | My Chain | next›

Tonight, I continue my efforts to finish off my poor man's Backbone.js collection caching.

After last night, I think I have a good overall approach. I might think this, but my Backbone application certainly disagrees. When I navigate back and forth between months in my calendar, appointments that should be cached steadily disappear until I am left with an empty calendar. I like having no scheduled appointments, but not if my application is removing them without just cause.

The overall approach is to instantiate my appointment collection for this month only:
          appointments = new Collections.Appointments({date: year_and_month});
I then handle the actual caching in the fetch() method. If the month being accessed already appears in the cache, then I try to trigger cache-hit events in the models. Otherwise, I fetch the resources from the server and add them to the collection:
        var Appointments = Backbone.Collection.extend({
          model: Models.Appointment,
          url: '/appointments',
          cached: {},
          initialize: function(options) {
            options || (options = {});
            this.date = options.date;
          },
          fetch: function(options) {
            options || (options = {});

            if (this.cached[this.date]) {
              var date = this.date;
              _.each(this.models, function(m) { /* trigger stuff on the models */ });
              return;
            }

            this.cached[this.date] = true;

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

            // Add fetched appointments to the existing list (don't reset)
            options.add = true;

            return Backbone.Collection.prototype.fetch.call(this, options);
          },
          //  ....
        });
My first problem, which I notice when I get models with only a date attribute, is that Collections in Backbone are initiated with a model and options. I am only supplying options:
          appointments = new Collections.Appointments({date: year_and_month});
My initialize method can handle this, but the rest of Backbone's collection initialization, which fires regardless of what initialize() does, cannot handle this. The rest of Backbone thinks I am trying to create a collection with a model {date: year_and_month}.

The solution is easy enough—pass in an empty model and then the options:
          appointments = new Collections.Appointments(undefined, {date: year_and_month});
The Appointments collection then requires a simple adjustment to ignore the first argument and pull the options from the second argument:
        var Appointments = Backbone.Collection.extend({
          model: Models.Appointment,
          url: '/appointments',
          cached: {},
          initialize: function(models, options) {
            options || (options = {});
            this.date = options.date;
          },
          // ...
        });
Cool beans. The initialize() method really needs to follow the same method signature as the other initialization methods in Backbone. The others are always called by Backbone regardless of what the initialize() method does. Bad things ensue if the initialize methods treats models as options or expects options where models should be.


The other problem was a little more subtle. In the fetch() method, I tried triggering add and deletes on the models in the collection in an attempt to fool Backbone into redrawing them:
//...
             if (this.cached[this.date]) {
               var date = this.date;
               _.each(this.models, function(m) {
                if (!m.get("startDate")) return;
                if (m.get("startDate").indexOf(date) > -1) {
                  m.trigger('change', m, this, options);
                }
                else {
                  m.trigger('destroy', m, this, options);
                }
//...
One could argue that I was asking for trouble. I was. But the kind of trouble was unexpected to me. Triggering the delete event wound up being a signal to the collection to remove the model. In hindsight that seems obvious, but I had somehow hope that, since I was triggering the event in the collection, the collection would not see the event. In the end, there is no magic that does this. Events are events and the collection that told the models to trigger the delete events eventually saw those same events. Once seen, the collection did the appropriate thing by removing models from the collection.

Anyhow the solution is to trigger a custom "cached" event:
//...
            if (this.cached[this.date]) {
              var date = this.date;
              _.each(this.models, function(m) {
                if (m.get("startDate").indexOf(date) === 0)
                  m.trigger('cached');
              });
              return;
            }
//...
I can then bind a listener in my view to render the appointment when a cache-hit is seen:
        var Appointment = Backbone.View.extend({
          template: _.template($('#calendar-appointment-template').html()),
          initialize: function(options) {
            /// ...
            options.model.bind('change', this.render, this);
            options.model.bind('cached', this.render, this);
          },
          // ...
        });
With that, I have my poor man's caching. I can move back and forth between October and November and back to October and always see the same 7 October appointments:
And the same one appointment for November:
Checking Chrome's Javascript console, I see the initial fetch of this month's appointments when the application loads. Then I see the fetch of November's appointments when I click "next". Any subsequent clicks on "next" or "previous" result in no XHR requests—because the appointments are already cached in the Backbone collection:
That is an OK solution. I hope to improve upon this tomorrow.


Day #171

4 comments:

  1. Chris - cool concept are you breaking up the fetch due to data size to fetch the entire collection? I applied a collection of models on a calendar view and used the filter function. This allowed me to change the range of dates dynamically (daily,weekly,monthly,3 month) and I can go forward and backward by reapplying the view on the filtered set of models. No refetching needed.

    ReplyDelete
  2. @Patrick I was going to suggest something similar. Fetch the month they are looking at into a master collection, and when they move months, create a new collection for that month and fetch into it, then add them all to the master and filter the view by month.

    Then the "cache" function simply looks in the master to see if we have events for that month.

    ReplyDelete
  3. @Patrick & @Nick -- I know what I'm playing with tonight! Thanks for the tips :)

    Yah, I was filtering because of the data size of the collection. Really, I was more wondering how to do it—I don't have that much data yet. But it definitely sounds as though you have a better handle on how to do it right than my first attempt. Thanks for sharing!

    ReplyDelete
  4. @Chris it got me thinking this morning actually. How about lazy loading all of the data outside of the current scope, so that the user gets the load of the calendar real quick for the current month, and then lazy load the rest so that changing months doesn't require any calls to the server. Alternatively, maybe just lazy load the next and previous months. Its cool to see someone working on a very similar problem to mine and take a different approach. Keep sharing! :)

    ReplyDelete