by Spike Brehm
Here at Airbnb, we’ve been looking curiously at Node.js for a long time now. We’ve used it for odds and ends, such as the build process for some of our libraries, but we hadn’t built anything production-scale. Until now.
There’s a disconnect in the way we build rich web apps these days. In order to provide a snappy, fluid UI, more and more of the application logic is moving to the client. In some cases, this promotes a nice, clean separation of concerns, with the server merely being a set of JSON endpoints and a place to host static files. This is the approach we’ve taken recently, using Backbone.js + Rails.
I’m proud to announce that we’ve launched our first Holy Grail app into production! We’ve successfully pulled Backbone onto the server using Node.js, tied together with a library we’re calling Rendr. We haven’t open-sourced Rendr quite yet, but you can expect it in the coming months, once we’ve had a chance to build a few more apps with it and decouple it from our use case a bit.
Gimme the Deets!
We built upon tools we already know and love: Backbone.js and Handlebars.js. We ended up with a hybrid of Rails, Backbone, and Node conventions. For example, the app’s directory structure will look familiar to Rails users (minus
In a typical Backbone app, “controller” logic—fetching the appropriate data and instantiating view(s) for a particular page—lives in methods on your instance of
Backbone.Router. As your app grows, however, the router quickly becomes bloated with all of these route handlers. This is why we’ve created real controller objects, which group related actions into more manageable chunks. This abstraction also allows us to more easily add before and after filters if we want to. Here’s what a users controller might look like:
First off, you’ll notice we’re using CoffeeScript. It’s a bit controversial, I know, but we’re fans. Secondly, notice the
module.exports = at the top. That’s a tell-tale sign of a CommonJS module. CommonJS is what Node.js uses to require modules, and we’re able to reuse the same syntax in the client using Stitch, which was written by the Sam Stephenson, the same fellow who also wrote Sprockets for Rails.
Now, keep in mind that this controller code gets executed on both the client and the server. For example, if a user lands on “/users/1234″, and a route exists that routes that to
users#show (more on that below), then the
show action will be invoked.
@app.fetcher.fetch you see is our way of encapsulating resource fetching. In this case, a
User model with id 1234 will be fetched from the API, instantiated, and passed to the view. Why not just instantiate the User model yourself in the controller? You could do that, but the
fetcher provides a layer of indirection which allows us to do a bunch of fancy caching in both the client and the server-side. I’ll leave out the details for now, but that could be the subject of a future blog post!
We need to be able to match a certain URL to a controller/action pair both on the client-side and the server-side. Inspired by Rails, we have a routes file that specifies these routes and any additional route parameters.
Notice the optional parameter
maxAge on the
listings#search route; this is used to set caching headers. You can add any arbitrary parameters here and access them in the router. We also plan to make this more advanced as needed, such as adding parameter requirements.
Our routes file format is heavily inspired by a really interesting project called ChaplinJS. Chaplin is an application framework built on top of Backbone. Chaplin is under active development and is quite well maintained; definitely worth a look if you want some more structure in your Backbone app.
These routes are parsed by a router, which delegates incoming requests to particular controllers. We have a
ClientRouter, which delegates to
Backbone.History and translates pushState events to controller actions, and a
ServerRouter, which does the same for Express requests; both extend
BaseRouter to share common logic.
We serve the app using Express, the de-facto web server for Node.js.
In Rendr, your views extend our
BaseView, which in turn extends
Backbone.View, adding a number of methods that allow it to easily render on both the server and the client. Here’s an example
ListingView for you:
First you’ll notice that we require
require('rendr/base/view'). This CommonJS module path is standard in Node.js for requiring files within NPM packages, and using a trick in our Stitch packaging, we can use the same path in the client.
In addition to the typical methods and properties from
Backbone.View, we’ve added some custom ones to hook into the view’s lifecycle. Notice the
#postRender() method; this gets called only in the client-side, right after rendering. This is where you would put any code that needs to touch the DOM, such as initializing jQuery plugins like slideshows or sliders.
Each view has a Handlebars template associated with it, and a
View#getHtml() method. On the server, we simply call
#getHtml() and return the resulting string. On the client,
#getHtml() and updates the innerHTML of its element.
We also have a
#getTemplateData() method, which by default returns
@model.toJSON(). You can override it to act as a view-model, adding any view-specific properties to pass to the template for rendering.
We decided on an entirely string-based templating approach, preferring not to depend upon a DOM on the server. One result of this is that it becomes necessary to push all HTML manipulation either into the Handlebars template or into a custom
#getHtml() method. In other words, you cannot rely on any DOM manipulation code to construct your markup; if you have a habit of appending child views, loading spinners, or any other markup in #render(), get used to pushing that down to the template.
Now, here’s the tricky part: when we generate a page of HTML from a hierarchy of views on the server, we also have to ensure that once it reaches the client, all of the views’ event handlers are properly bound to the DOM, and we have living, breathing view instances that can respond to user interactions. Our approach was to decorate the generated HTML with data-attributes, which specify which view class it represents. Here’s an example from one of our listing pages:
On pageload, we find all DOM elements with the
data-view="some_view_class" attribute, instantiate view objects for each, and reconstruct the view hierarchy. That way, events are properly bound, and we preserve any parent-child relationships between our view objects so they can listen to and emit events on each other.
Sounds easy enough, right? Well, it ended up being more difficult to solve than we expected. One of the problems we ran into is that all data needed to instantiate a view needs to be extractable from the DOM. You’ll notice in the above code snippet that we also have the data-attributes
data-model_id. This allows us to pass in the correct model or collection into any view, fetching it from the client-side model cache (remember
@app.fetcher from our controller? it handles this for us). This is the pattern we came up with in order to ship; a better approach would be to assign a unique id to each view instance on the server, and have some sort of mapping from view id to view data which we could read from in the client, rather than looking up from the DOM.
There’s another way to ensure views are properly bound to the DOM, which we decided against. It involves discarding the server-generated HTML upon pageload, regenerating the view hierarchy in the client, and swapping the view DOM tree into the page. The downside is the potential for weird UX interactions if, for example, a user starts to interact with elements on the page that then get destroyed and replaced.
We really like enforcing encapsulation within our views. We also wanted an easy way to declaratively nest subviews within any view. We came up with a nifty way of achieving both using Handlebars helpers. Check out this slightly contrived example.
Given these views and templates:
and this data:
View#getHtml() on the parent returns this HTML:
So, you can arbitrarily nest views using a simple Handlebars helper, and simply call
#getHtml() on the top-most view to get the entire hierarchy’s HTML. Nifty, eh?
We found this useful enough as a standalone library that we’ve pulled it out into NestedView. Check it out on Github.
Models & Collections
Now onto models and collections.
Rendr has a
BaseCollection, which extend
Backbone.Collection. Here’s the most basic
Pretty thin, right? Of course, you can add whatever custom methods you want to your models.
The important part is the
url property. Rendr expects to get all of its data from a JSON API over HTTP. This could be on the same server, but in our case, it’s a preexisting API that powers a number of other apps. The
url specified above points to the path on this API server.
Calling Backbone’s CRUD methods on the model or collection (
#destroy(), and now
#update() in the latest Backbone) will dispatch a request to this API. We override
Backbone.sync() such that when these methods are called from the server-side, Rendr sends an HTTP request directly to the API server. When called from the client, Rendr will prepend
/api to the model/collection URL, proxying the request to the API through the Rendr server. This allows us to do any additional formatting of the request or response, and to centralize the API request and response handling logic, at the expense of some network time.
Sprechen Sie Deutsch?
In English (and German, Spanish, Italian, and a few others) there are only two plural forms: singular and not-singular.
However, some languages get a bit more complicated. In Czech, there are three separate forms: 1, 2 through 4, and 5 and up. Russian is even crazier.
Polyglot.js abstracts all that away from you, and just requires you to provide translations for whichever locales you’re interested in.