Using Backbone.js for sanity and stability
cfbot get tableflip
For starters, I had a great experience with using it to power Syllabus (Chorus’ liveblogging tool), and knew that the basic requirements for Syllabus were the same as for comments: a live updating list of short posts.
Collections, Models, and Views
Distinct collection, model and view objects separate data manipulation (in the collection + model) from DOM manipulation (in the view), which used to be all mixed up in the same place.
Declarative events isolate the selectors used by js for binding events. Instead of scattering these selectors inside functions and nested callbacks, having a single point of reference makes it easy to refactor markup and styles safely.
The Backbone.Events mixin, used to create an event-driven architecture, helps to decouple sections of code with separate concerns. Removing direct references between unrelated sections of code allows reasoning about smaller units of functionality at one time.
With these patterns in mind, I started writing code. I began with just a model and a collection, wiring them up to load data from the server, and inspect it in the browser console. I was able to write unit tests to verify this was happening correctly. Then I gradually built view upon view, adding model and collection functionality as required, until I had feature parity with the old system.
I ended up with 25 files (3 collections, 3 models, 17 views, 1 mixin, and 1 main), and 2000 lines of code. Slightly up from 1800 lines from before, but much more manageable with the largest file being 400 lines (the comment model).
In addition to the above three patterns afforded by Backbone.js, I came up with several new patterns to help keep my code organized and DRY.
Killing boilerplate view code with compileTemplates()
In the main initialization code, I call a compileTemplates() function that loops through every view in a namespace, translating the templateId property of each into a template function. This makes views more DRY by extracting boilerplate template setup code.
Replacing Backbone.sync for working around an existing non-RESTful API
Because of an existing non-RESTful API, it was difficult to wire up my comment model and collection to use Backbone.sync for fetching and saving models. So I decided instead to abandon Backbone.sync completely, and write an `ajax` method to provide a simple wrapper around `jQuery.ajax`.
Because I needed similar functionality in both models and collections, I wrote a mixin that both could use: https://gist.github.com/thomsbg/6527302. This mixin provides several functions and configuration hooks for working with ajax requests:
- Objects including this mixin can specify a `urls` property, which the `urlFor` function will use to translate an action name to a URL, using Rails-style /:bound/path/:segments.
- Objects may also specify an `ajaxOptions` property, which provides default options to pass to `jQuery.ajax`. For example, this is used in one model to specify that all ajax requests should use the ‘POST’ method.
- When using the `ajax` method brought in with this mixin, some default events are fired, based on the action name passed as the first argument. i.e. calling `this.ajax(‘foo’)` triggers the `fooStart`, `fooError`, and `fooSuccess` events, as appropriate. Generic-named events are fired on a global event mediator as well: ‘ajaxStart’, ‘ajaxError’, and ‘ajaxSuccess’.
- Specifying the error and success settings in the options to `this.ajax` allows those callbacks to run first, before the applicable event is fired.
As a full example, inside of a model you can call `this.ajax` with an action name, callbacks, and other options: https://gist.github.com/6541380.
Optimizing the event-binding bottleneck on large comment threads
A basic solution to rendering a list of comments is to instantiate and render a collection of Backbone view objects, one per comment. Ordinarily, every Backbone view calls delegateEvents() when it is initialized, which handles binding event handlers based on the contents of the `events` object. When rendering a massive collection of comments, doing this event binding once per comment slows things down. I used console.profile() to determine that it took around 1-2 seconds to bind events for a list of 500 comments (5-10ms for each call to delegateEvents()).
One way to solve this would be to just not use separate view objects for each comment, and have one super view that handles events for the entire comment list. But for code clarity, I wanted to keep the concerns of rendering a list of comments separate from those of rendering and reacting to events of a single comment. So instead I performed some trickery to bind events on the list once, while keeping the event definition in the child: https://gist.github.com/thomsbg/6527888.
This code redefines delegateEvents() on the per-comment child view to be a no-op, while supercharging delegateEvents() on the parent view in charge of rendering the entire list. When an event happens inside of a child, it eventually bubbles up to the parent. The parent is able to work out which child view object should respond to the event based on information in the DOM (id attribute), and call the appropriate method on that object. With this optimization, I saved that 5-10ms for each comment rendered, helping large threads render 1-2 seconds faster.
I had a couple surprises working with Backbone this time around. First of all, Backbone.sync doesn’t support more than the standard CRUD actions, but my API has separate endpoints for different update operations (recommend, flag, edit, etc). You could make a case to restructure these operations into sub-resources, but that was beyond the scope of this project. I expected the Backbone.sync mechanism to be little more flexible. Thus, my custom jQuery.ajax wrapper was born.
Secondly, I was surprised to find that Collection add events fire in the order of the array passed to collection.add(), not in sorted order. This made adding element views to a sorted collection tricky. Instead of simply appending views to a parent element, the appropriate index for each view added needs to be found, and used with insertBefore().
I initially called e.preventDefault() inside event handler methods, but sometimes I wanted to call those methods without an event parameter (such as from the browser console). I changed them all to return false instead, to allow them to be called without any arguments. This made it easy to explore and trigger methods manually via the console.
I purposefully tried not to use 3rd party libraries (such as marionette), because I was worried about too many abstractions getting in the way. I eventually re-invented some features I could have gotten for free (e.g. `compileTemplates()`), but it was worth it to learn deeply about the framework, and to have complete control over my code.
It’s still easy to introduce coupling when using Backbone. I rigorously kept all model-to-view references out of my code, but it would have been very easy to introduce them. Doing so would have defeated the purpose of my refactor, but another developer (perhaps future me) might want to in order to fix a bug or add a feature. cfbot gif me sad
Working with an API that returns underscored_attribute_names is jarring when working with a snakeCase coding convention for variable and function names. However, I grew to like the visual distinction between server-generated attributes, and client side variables.
Writing tests seemed like a chore at first, but grew to be a gratifying way to work with the code I had written, validating thought experiments and keeping me sane when refactoring. Code coverage went from 0% before with no tests at all to ~50% afterwards, with 60 tests and 150 assertions.
Because each vertical uses its own templates and options to control the appearance and features in the comments library, writing effective, isolated tests for view objects didn’t seem worth it. Perhaps I just haven’t found the right pattern that allows for testing the right things yet.
Was it worth it?
Yes, a thousand times yes. Not only does this new code perform better than the old, it provides us with an extensible system for implementing new site features powered by comments. For example, it has already been used with great success to power SB Nation Live, a gameday chat room experience. Without this starting point to work from, completing that project would have been more painful, and taken much longer. Thanks Backbone!