Single Page Web App Architecture Done Right

2011-07-26

The holy grail of web application architecture is not the single-page application. It is the single-page application that works as a multi-page application if you don’t have Javascript enabled. Here’s the right way to write one.

It’s simple to describe. You first make it work as a multi-page application, preferably without Javascript. Then you enhance it to work as a single-page application if the web browser will support one. This approach has a name. It’s called Hijax.

An application using this architecture cannot use the URL hash. The hash, after all, is not sent to the server and will not be seen without Javascript. That means that if you do use the URL hash, then bookmarks to parts of your site will break when transferred between Javascript-enabled and non-Javascript-enabled browsers. The application must instead use the HTML 5 history API, which includes the famous pushState verb.

Furthermore, we can maintain DRYness and reduce the amount of duplicate code by using the same templates for both the client-side code and the server-side code. This is the approach recommended by both of the following conference talks:

Accomplishing this requires a templating engine that has support for both Javascript and for whatever language you’re using for the backend. This probably means mustache, which has support for most server-side languages. If your backend is Java, then Google’s Closure Templates are another option. You will also need a backend framework that gives you the freedom to use the templating language or “view engine” of your choice.

On the client side, you’ll need to be able to listen for changes in the location (not the hash, the location) and, again, to easily choose your templating engine.

In this example, we’ll use Django on the server and Backbone on the client.

There’s nothing about Django that requires using the template language. Backbone is agnostic with respect to your preferred method of HTML templating.

Although Django supplies a templating engine, it allows you to replace it with whatever you want.

Backbone fulfills the same requirement. It also supports the HTML5 history API.

First, we’ll build an application where all the logic is server-side. We’ll also provide URLs to load the templates from the server, for use in our client-side code.

Our routes, as defined in urls.py, looks like this:

urlpatterns = patterns('', ('^names$', 'names.views.index'), ('^names\/1$', 'names.views.edit'), ('^templates\/index$', 'names.views.index_template'), ('^templates\/edit$', 'names.views.edit_template'),

This is followed by the following views (what many other frameworks call controllers), defined in views.py:

from django import http import pystache import settings import templates pystache.View.template_path = settings.MEDIA_ROOT def index(request): outline_template = templates.Outline() index_template = templates.Index() index_template.set_names([{ 'id': '1', 'name': 'Joe', }]) outline_template.set_body(index_template) return http.HttpResponse(outline_template.render()) def edit(request): outline_template = templates.Outline() edit_template = templates.Edit() edit_template.set_name('Joe') outline_template.set_body(edit_template) return http.HttpResponse(outline_template.render()) def index_template(request): index_template = templates.Index() return http.HttpResponse(index_template.load_template()) def edit_template(request): edit_template = templates.Edit() return http.HttpResponse(edit_template.load_template())

Note that for the purposes of simplicity, we’re hard-coding the data.

The referenced Mustache templates are defined as follows, in templates.py:

import pystache class Index(pystache.View): def set_names(self, names): self.__names = names def names(self): return self.__names class Edit(pystache.View): def set_name(self, name): self.__name = name def name(self): return self.__name class Outline(pystache.View): def set_body(self, body): self.__body = body def body(self): return self.__body.render()

The templates load Mustache templates, based on the class names. They are defined as follows:

Here is outline.mustache:

<html> <head> <title>the title</title> <meta charset="utf-8"> <link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1/themes/smoothness/jquery-ui.css"> </head> <body> {{{body}}} <script src="http://ajax.aspnetcdn.com/ajax/modernizr/modernizr-2.0.6-development-only.js"></script> <script src="http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.js"></script> <script src="http://ajax.googleapis.com/ajax/libs/jqueryui/1/jquery-ui.js"></script> <script src="/static/jquery.mustache.js"></script> <script src="/static/underscore.js"></script> <script src="/static/backbone.js"></script> <script src="/static/sample.js"></script> </body> </html>

Here is index.mustache:

<table class="ui-widget"> <thead class="ui-widget-header"> <tr> <th>Names</th> </tr> </thead> <tbody class="ui-widget-content"> {{#names}} <tr> <td> <a href="/names/{{id}}">{{name}}</a> </td> </tr> {{/names}} </tbody> </table>

And here is edit.mustache:

<form> <table class="ui-widget"> <thead class="ui-widget-header"> <tr> <th colspan="2">Edit Name</th> </tr> </thead> <tbody> <tr> <td> <label for="name">Name:</label> </td> <td> <input type="text" id="name" value="{{name}}" readonly="readonly"> </td> </tr> </tbody> </table> <p><a href="/names">Go Back</a></p> </form>

That takes care of the server-side code.

For the client-side code, we’ll first detect whether the browser supports the HTML5 history API. If not, we’ll do nothing. Otherwise, we’ll load the templates from the server and start listening for location changes. We’ll also add handlers to everything that would send a request to the server: all internal links and submit buttons. Each handler puts the application into the same state that the call to the server would, and then cancels the event.

Here’s the Javascript code:

$(function () { var loadAndRun = function () { var templates = {}, views = {}, runApp = function () { var List = Backbone.View.extend({ events: { 'click .ui-widget-content': 'handleClick' }, handleClick: function (event) { event.preventDefault(); if ($(event.target).attr('href') !== undefined) { Backbone.history.navigate('names/1', true); } }, initialize: function () { _(this).bindAll('handleClick', 'render'); }, render: function () { $(this.el).html($.mustache(templates.index, views.index)); } }), Edit = Backbone.View.extend({ events: { 'click a': 'handleClick' }, handleClick: function (event) { event.preventDefault(); if ($(event.target).attr('href') !== undefined) { Backbone.history.navigate('names', true); } }, initialize: function () { _(this).bindAll('handleClick', 'render'); }, render: function () { $(this.el).html($.mustache(templates.edit, views.edit)); this.delegateEvents(); } }), Router = Backbone.Router.extend({ routes: { 'names': 'list', 'names/:id': 'details', }, list: function () { var view = new List({ el: document.body }); view.render(); }, details: function (id) { var view = new Edit({ el: document.body }); view.render(); } }); new Router(); Backbone.history.start({ pushState: true }); }; views.index = { names: [{id: '1', 'name': 'Joe'}] }; views.edit = { name: 'Joe' } $.get('/templates/index', null, function (data) { templates.index = data; $.get('/templates/edit', null, function (data) { templates.edit = data; runApp(); }); }); }; if (Modernizr.history) { loadAndRun(); } });

Note that the views are rendered the same way, and with the same templates, in both the server-side code and the client-side code.