Dugan Chen's Homepage

Various Things

Backbone.js sample app

For writing a single-page web app, you want something more structured than jQuery. You want a framework that gives you a model-view-something pattern to organize your code around. Google has two: GWT and Closure. Cappucino and Sproutcore are two other choices. Most of these are meant to be used instead of jQuery. Three others, Knockout.js (made by a Microsoft developer), Javascript MVC and Backbone.js (neither connected with Microsoft), are meant to be used in addition to jQuery.

Here is a sample application that uses most of Backbone.js’s features.

First, there’s the HTML page:

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="utf-8">
		<title>Backbone.js SPI</title>
	</head>
	<body>
		<ul>
			<li><a id="os-link" href="#/os/">Operating System</a></li>
			<li><a id="cpu-link" href="#/cpu/">Central Processing Unit</a></li>
		</ul>
		<div id="editor"></div>
		<div id="data"></div>
		<script src="http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
		<script src="underscore-min.js"></script>
		<script src="backbone-min.js"></script>
		<script src="soyutils.js"></script>
		<script src="cpu_templates.js"></script>
		<script src="os_templates.js"></script>
		<script src="backbone-spi.js"></script>
	</body>
</html>

And then we have two Closure templates:

{namespace SPI.CPU.Templates}

/**
 * The CPU editing field.
 * @param value The field's value.
 */
{template .editor}
	<label for="cpu-input">Central Processing Unit:</label>
	<input type="text" id="cpu-input" value="{$value}">
{/template}

/**
 * The CPU data display field.
 * @param value The field's value.
 */
{template .data}
	<p>
		CPU: {$value}
	</p>
{/template}
{namespace SPI.OS.Templates}

/**
 * A new operating system. To be added.
 */
{template .newOS}
	<p>
		<input id="os-input" placeholder="Operating System" type="text">
		<button>Add</button>
	</p>
{/template}

/**
 * An OS in the list.
 * @param name The name of the OS.
 * @param count The number of installations.
 */
{template .os}
	<td><input type="number" value="{$count}"></td>
	<td><button>Remove</button></td>
	<td>{$name}</td>
{/template}

/**
 * The table of operating systems.
 */
{template .table}
	<p>
		Number of operating systems: <span id="os-count"></span>
	</p>
	<table>
		<thead>
			<tr>
				<th></th>
				<th></th>
				<th>Operating System</th>
			</tr>
		</thead>
		<tbody>
		</tbody>
	</table>
{/template}

And this is the Javascript file. It’s maintainable and it works:

/*global _, $, Backbone */

var SPI = SPI || {};

SPI.CPU = SPI.CPU || {};

SPI.CPU.Models = SPI.CPU.Models || {};

SPI.CPU.Models.CPU = Backbone.Model.extend({
	initialize: function () {
		this.set({'cpu': ''});
	}
});

SPI.CPU.Views = {};

SPI.CPU.Views.Editor = Backbone.View.extend({
	cpuInput: function () {
		var href = $('#cpu-link').attr('href'),
			val = $('#cpu-input').val();
		if (val.length > 0) {
			href = '#/cpu/' + val + '/';
			$('#cpu-link').attr('href', href);
			Backbone.history.saveLocation('/cpu/' + val + '/');
		} else {
			$('#cpu-link').attr('href', '#/cpu/');
			Backbone.history.saveLocation('/cpu/');
		}
		this.model.set({'cpu': $('#cpu-input').val()});
	},
	el: $('#editor')[0],
	events: {
		'keyup input': 'cpuInput' 
	},
	render: function () {
		$(this.el).html(SPI.CPU.Templates.editor({
			value: this.model.get('cpu')
		}));
		
		this.delegateEvents();
	}
});

SPI.CPU.Views.Data = Backbone.View.extend({
	initialize: function () {
		_(this).bindAll('render');
		this.model.bind('change', this.render);
	},
	el: $('#data')[0],
	render: function () {
		$(this.el).html(SPI.CPU.Templates.data({
			value: this.model.get('cpu')
		}));
	}
});

SPI.OS = SPI.OS || {};

SPI.OS.Model = Backbone.Model.extend({
	initialize: function () {
		this.set({
			'count': 0
		});
	}
});

SPI.OS.Collection = Backbone.Collection.extend({
	model: SPI.OS.Model
});

SPI.OS.Views = SPI.OS.Views || {};

SPI.OS.Views.NewOS = Backbone.View.extend({
	el: $('#editor')[0],
	events: {
		'click button': 'addOS'
	},
	initialize: function () {
		_(this).bindAll('render');
	},
	addOS: function () {
		var val = $.trim($('input').val());
		if (val.length > 0) {
			this.collection.add(new SPI.OS.Model({
				count: 0,
				name: $('input').val()
			}));
		}
	},
	render: function () {
		$(this.el).html(SPI.OS.Templates.newOS());
		this.delegateEvents();
	}
});

SPI.OS.Views.OS = Backbone.View.extend({
	change: function () {
		var val = parseInt($(this.el).find('input').val(), 10);
		this.model.set({
			'count': val
		});
	},
	events: {
		'change input': 'change',
		'click button': 'remove'
	},
	initialize: function () {
		_(this).bindAll('change', 'remove', 'render');
	},
	remove: function () {
		this.collection.remove(this.model);
	},
	render: function () {
		$(this.el).html(SPI.OS.Templates.os({
			count: this.model.get('count'),
			name: this.model.get('name')
		}));
		this.delegateEvents();
	}
});

SPI.OS.Views.List = Backbone.View.extend({
	el: $('#data')[0],
	initialize: function () {
		_(this).bindAll('render');
	},
	render: function () {
		$(this.el).html(SPI.OS.Templates.table());
	}
});

SPI.OS.Views.TBody = Backbone.View.extend({
	add: function (model) {
		var view = new SPI.OS.Views.OS({
			el: $('<tr>').appendTo(this.el)[0],
			model: model,
			collection: this.collection
		});
		this.views.push(view);
		view.render();
	},
	initialize: function () {
		_(this).bindAll('add', 'remove', 'render');
		this.views = [];

		this.collection.bind('add', this.add);
		this.collection.bind('remove', this.remove);
	},

	remove: function (model) {
		var i;
		for (i = 0; i < this.views.length; i += 1) {
			if (this.views[i].model === model) {
				$(this.views[i].el).remove();
				this.views.splice(i, 1);
				break;
			}
		}
	},

	render: function () {
		var that = this;
		_(this.views).each(function (view) {
			view.el = $('<tr>').appendTo(that.el)[0];
			view.render();
		});
	}
});


SPI.OS.Views.Count = Backbone.View.extend({
	initialize: function () {
		_(this).bindAll('render');
		this.collection.bind('change', this.render);
		this.collection.bind('remove', this.render);
	},
	render: function () {
		var count = 0;
		this.collection.each(function (os) {
			count += os.get('count');
		});

		$(this.el).html(count);
	}
});

SPI.Controller = Backbone.Controller.extend({
	initialize: function () {
		var cpu = {},
			os = {},
			osCollection = new SPI.OS.Collection(); 
		cpu.model = new SPI.CPU.Models.CPU();
		cpu.views = {};
		cpu.views.editor = new SPI.CPU.Views.Editor({
			model: cpu.model
		});

		cpu.views.data = new SPI.CPU.Views.Data({
			model: cpu.model
		});

		os.views = {};
		os.views.newOS = new SPI.OS.Views.NewOS({
			collection: osCollection
		});

		os.views.list = new SPI.OS.Views.List({
			collection: osCollection
		});

		os.views.count = new SPI.OS.Views.Count({
			collection: osCollection
		});

		os.views.tbody = new SPI.OS.Views.TBody({
			collection: osCollection
		});

		this.route(/\/cpu\/(?:(\w+)\/)?$/, 'cpu', function (value) {
			if (value === undefined) {
				value = '';
			}

			cpu.model.set({
				'cpu': value
			}, {
				silent: true
			});

			cpu.views.editor.render();
			cpu.views.data.render();
		});

		this.route(/\/os\/$/, 'os', function () {
			os.views.newOS.render();
			os.views.list.render();
			os.views.count.el = $('#os-count')[0];
			os.views.count.render();
			os.views.tbody.el = $('table > tbody')[0];
			os.views.tbody.render();
		});
	}
});


$(function () {
	window.controller = new SPI.Controller();
	Backbone.history.start();
	window.location.hash = '#/cpu/';
});

Expect a future update to feature the same app written with Knockout.js and Sammy.js.