Monday, September 26, 2011

Singleton Views

‹prev | My Chain | next›

Last night, I ran into an unexpected conflict between Backbone.js and external widgeting libraries. In this case, I was using jQuery UI dialogs to edit and create calendar appointments. The conflict arose when the dialog was opened for a second time, resulting in a second copy of the same Backbone event handlers being attached. Hilarity ensued:
The eventual fix took some exploration, but was ultimately very small. Per a comment from my co-author on the forthcoming Recipes with Backbone, however, there is a better solution. It turns out to be one of the many recipes in the book. At the risk of putting giving away too much of the coolness that will be our book, I will endeavor to implement said recipe—the singleton view.

The idea behind the singleton view is that certain Backbone views, such as those attached to widgets from external libraries, only need to be instantiated once. They can then be reset() on demand.

My non-singleton view looks like a typical Backbone view:
        var AppointmentAdd = Backbone.View.extend({...});

        var Day = Backbone.View.extend({
          events : {
            'click': 'addClick'
          },
          addClick: function(e) {
            console.log("addClick");

            var add_view = new AppointmentAdd({
              // The id attributes of my calendar are ISO 8601 dates
              startDate: this.el.id
            });
            add_view.render();
          }
        });
The AppointmentAdd class is defined as an extension of the standard Backbone.View. It then gets initialized and rendered when the day view is clicked. In addition to requiring extra code to prevent duplicate event handlers from being attached to the jQuery UI dialog inside AppointmentAdd, I have a more fundamental problem. I am creating a "new" add-appointment view without creating a new, associated element. In retrospect, problems were bound to occur.

At any rate, the singleton view to the rescue. The idea here is, instead of a class, create an instance of a Backbone view. In this case, the AppointmentAdd view is no longer a class, but an instance of an anonymous class:
        var AppointmentAdd = new Backbone.View.extend({...}));

        var Day = Backbone.View.extend({
          events : {
            'click': 'addClick'
          },
          addClick: function(e) {
            console.log("addClick");

            AppointmentAdd.reset({startDate: this.el.id});
          }
        });
I can then call reset() on that singleton instance whenever the user clicks on the day. Now, there is no need to futz with any View internals to get the add-appointment view to attach the events correctly—the add-appointment view is only instantiated once and the events are attached only once. The reset() is then responsible for assigning the default startDate for the add-appointment dialog and for telling the dialog to render itself:
        var AppointmentAdd = new Backbone.View.extend({
          reset: function(options) {
            this.startDate = options.startDate;
            this.render();
          },
          // ...
        });
Unfortunately, this does not work. Instead of opening the dialog upon click, nothing happens. Nothing except for an error message in Chrome's Javascript console:
Uncaught TypeError: Object function (){ return parent.apply(this, arguments); } has no method 'reset'
Hunh?! There most certainly is a reset() method!

What gives?

Well, what gives is Javascript operator precedence. As much as I would like to believe that the extend() call in new Backbone.View.extend({ /* ... */}) is a keyword, it is not. It is an underscore.js / Backbone method that is called on the Backbone.View object. The thing is, method calls have a lower precedence than both new and object lookup (e.g. Backbone.View). In other words, instead of getting this:
new ( Backbone.View.extend({ /* ... */}) )
What I am really getting is this:
(new Backbone.View).extend({ /* ... */})
Put another way:
> foo = function() { return { hello: function() {console.log("hello")} } }
[Function]
> it = new foo
{ hello: [Function] }
> it.hello()
hello

> bar = function() { return foo }
[Function]
> it = new bar()
[Function]
> it.hello()
TypeError: Object function () { return { hello: function() {console.log("hello")} } } has no method 'hello'
    at repl:1:4
    at Interface. (repl.js:168:22)
    at Interface.emit (events.js:67:17)
    at Interface._onLine (readline.js:153:10)
    at Interface._line (readline.js:408:8)
    at Interface._ttyWrite (readline.js:585:14)
    at ReadStream. (readline.js:73:12)
    at ReadStream.emit (events.js:88:20)
    at ReadStream._emitKey (tty_posix.js:306:10)
    at ReadStream.onData (tty_posix.js:69:12)
> it = new (bar())
{ hello: [Function] }
> it.hello()
hello
Thankfully, the solution is simple enough—don't skimp on the parentheses:
        var AppointmentAdd = new (Backbone.View.extend({...}));
With that, I have my singleton add-appointment view working:
Diversions into Javascript operator precedence aside, the singleton view recipe is full of win. It applies much cleaner into my domain and does not require any funky workarounds.


Day #145

1 comment:

  1. Hooray for the book! Definitely been bitten by the parens thing myself.

    ReplyDelete