Now generating source code through Noweb, and including commentary. Woot!

This commit is contained in:
Elf M. Sternberg 2010-12-08 17:33:44 -08:00
parent bedb20bb0b
commit 66586d8aad
3 changed files with 504 additions and 45 deletions

467
backbonestore.nw Normal file
View File

@ -0,0 +1,467 @@
\documentclass{article}
\usepackage{noweb}
\usepackage{hyperref}
\begin{document}
I've been learning how to use \nwanchorto{http://documentcloud.github.com/backbone/}{Backbone.js}, a nifty little library for
organizing your client-side Javascript into a classic
Model-View-Controller paradigm while trying (and to some extent,
succeeding) in trying to burden you, the user, with as little
additional learning as possible. I consider this a good thing; the
overhead of learning a library and its accompaning DSL represent
additional cognitive loads that developers can better use elsewhere.
Keeping as much as possible within familiar paradigms is not just
useful, it's necessary as our programs get bigger.
The tutorial for Backbone is woefully lacking in specifics, and the
example program, Todo, doesn't really have much chops in teaching you
the ins and outs of Backbone, especially not its new Controller and
History modules. But in the announcement for Backbone.Controller,
Jeremy Ashkenas hid a clue: There's another library, Sammy.js, that
does something similar, and they do have a tutorial called \nwanchorto{http://code.quirkey.com/sammy/tutorials/json_store_part1.html}{The JsonStore}.
In the spirit of The JSON Store, I present The Backbone Store, and
online store written entirely in JSON and operating entirely within a
single page.
Let's start by showing you the HTML that we're going to be exploiting:
<<index.html>>=
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>The Backbone Store</title>
<link rel="stylesheet" href="jsonstore.css" type="text/css" media="screen" charset="utf-8" />
<<index template>>
<<product template>>
</head>
<body>
<div id="container">
<div id="header">
<h1>
The Backbone Store
</h1>
<div class="cart-info">
My Cart (<span class="cart-items">0</span> items)
</div>
</div>
<div id="main">
</div>
</div>
<script src="jquery-1.4.4.min.js" type="text/javascript"></script>
<script src="jquery.tmpl.min.js" type="text/javascript"></script>
<script src="underscore.js" type="text/javascript"></script>
<script src="backbone.js" type="text/javascript"></script>
<script src="store.js" type="text/javascript"></script>
</body>
</html>
@
This is taken, more or less, straight from The JSON Store. I've
included one extra thing, aside from jQuery and Backbone, and that's
the \nwanchorto{https://github.com/jquery/jquery-tmpl}{jQuery Templates kit}. We'll discuss those in a minute. There's a
simplified JSON file that comes in the download; it contains six
record albums that the store sells. (Unlike the JSON store, these
albums don't exist; the covers were generated during a round of \nwanchorto{http://elfs.livejournal.com/756709.html}{The Album Cover
Game}.)
There are two views, the index and the item. So, using
[[Backbone.Controller]], we're going to route the following:
<<routes>>=
routes: {
"": "index",
"item/:id": "item",
},
@
Unlike Sammy, Backbone mostly only routes GET commands. Routes are to
routes to views; everything else happens more or less under the
covers.
As Backbone is running, the [[Backbone.History]] module is listening to
the hash object, waiting for it to change so that it can trigger a
``route'' event, in which case the function named as the value in the
route is called.
There are a few things I want to track: the index view, the individual
product views, and the shopping cart.
<<application variables>>=
_index: null,
_products: null,
_cart :null,
@
Using backbone, I have a list of products. So, I should declare
those. The basic product is just a model, with nothing to show for
it; the list of products is a [[Backbone.Collection]], with one feature,
the [[comparator]], which sorts the albums in order by album title.
<<product models>>=
var Product = Backbone.Model.extend({});
var ProductCollection = Backbone.Collection.extend({
model: Product,
comparator: function(item) {
return item.get('title');
}
});
@
That's not very exciting. Let's show this list, with a View. Let's
call it IndexView:
<<index view>>=
var IndexView = Backbone.View.extend({
el: $('#main'),
indexTemplate: $("#indexTmpl").template(),
render: function() {
var sg = this;
this.el.fadeOut('fast', function() {
sg.el.empty();
$.tmpl(sg.indexTemplate, sg.model.toArray()).appendTo(sg.el);
sg.el.fadeIn('fast');
});
return this;
}
});
@
This code defines a [[Backbone.View]] object, in which the parent element
is #main, the render function fades out the existing elements in that
position and replaces them with the contents of a rendered jQuery
template, and then fades the element back in.
The index template looks like this:
<<index template>>=
<script id="indexTmpl" type="text/x-jquery-tmpl">
<div class="item">
<div class="item-image">
<a href="#item/${cid}"><img src="${attributes.image}" alt="${attributes.title}" /></a>
</div>
<div class="item-artist">${attributes.artist}</div>
<div class="item-title">${attributes.title}</div>
<div class="item-price">$${attributes.price}</div>
</div>
</script>
@
There's some
\nwanchorto{http://en.wikipedia.org/wiki/Law_of_Demeter}{Demeter violations}
going on here, in that I have to know about the [[attributes]] of a
Backbone model, something that's normally hidden within the class.
But this is good enough for our purposes. The above is a jQuery
template, and the [[\$\{\}]] syntax is what's used to dereference
variables within a template.
(As an aside, I think that the [[set]] and [[get]] methods of
[[Backbone.Model]] are a poor access mechanism. I understand why they're
there, and I can only hope that someday
\nwanchorto{http://ejohn.org/blog/javascript-getters-and-setters/}{Javascript
Getter and Setters} become so well-established as to make [[set]]
and [[get]] irrelevant.)
Now, we can render the index view:
<<index render call>>=
index: function() {
this._index.render();
},
@
At this point, well, we need an application. A controller. And we
need to initialize it, and call it. Here's what it looks like (some
of this, you've already seen):
<<workspace>>=
var Workspace = Backbone.Controller.extend({
<<application variables>>
<<routes>>
<<initialization>>
<<index render call>>
<<product render call>>
});
workspace = new Workspace();
Backbone.history.start();
@
There are two things left in our workspace, that we haven't defined.
The intialization, and the product render.
Initialization consists of getting our product list, creating a
shopping cart to hold ``desired'' products (and in quantity!), and
creating the index view. (Product views, we'll discuss in a moment).
<<initialization>>=
initialize: function() {
var ws = this;
if (this._index === null) {
$.ajax({
url: 'data/items.json',
dataType: 'json',
data: {},
success: function(data) {
ws._cart = new Cart();
new CartView({model: ws._cart});
ws._products = new ProductCollection(data);
ws._index = new IndexView({model: ws._products});
Backbone.history.loadUrl();
}
});
return this;
}
return this;
},
@
We haven't defined the Cart yet, but that's all right. We'll get to
it.) But here you see a lot of what's already existent being used: we
get a ProductCollection, and an IndexView.
That last line is curious. It's an instruction to Backbone to look at
the URL; if the user navigated to something other than the home page,
it's to use the routes defined to go there. Users can now bookmark
places in your site other than the home page. Yes, the bookmark will
be funny and have at least one
\nwanchorto{http://en.wiktionary.org/wiki/octothorpe}{octothorpe} in it, but
it will work.
Let's deal with the shopping cart:
<<shopping cart models>>=
var CartItem = Backbone.Model.extend({
update: function(amount) {
this.set({'quantity': this.get('quantity') + amount});
}
});
var Cart = Backbone.Collection.extend({
model: CartItem,
getByPid: function(pid) {
return this.detect(function(obj) { return (obj.get('product').cid == pid); });
},
});
@
A little rocket science here: A [[Cart]] contains [[CartItems]]. Each
``item'' represents a quantity of a [[Product]]. (I know, that always
struck me as odd, but that's how most online stores do it.)
[[CartItem]] has an update method that allows you to add more (but not
remove any-- hey, the Sammy store wasn't any smarter, and this is For
Demonstration Purposes Only), and we use the [[set]] method to make
sure that a ``change'' event is triggered.
The [[Cart]], in turn, has a method, getByPid (``Product ID''), which
is meant to assist other objects in finding the [[CartItem]]
associated with a specific product. Here, I'm just using the Backbone
default client id.
The cart is represented by a little tag in the upper right-hand corner
of the view; it never goes away, and its count is always the total
number of [[Products]] (not [[CartItem]]s) ordered. So the
[[CartView]] needs to update whenever a [[CartItem]] is added or
updated. And we want a nifty little animation to go with it:
<<shopping cart view>>=
var CartView = Backbone.View.extend({
el: $('.cart-info'),
initialize: function() {
this.model.bind('change', _.bind(this.render, this));
},
render: function() {
var sum = this.model.reduce(function(m, n) { return m + n.get('quantity'); }, 0);
this.el
.find('.cart-items').text(sum).end()
.animate({paddingTop: '30px'})
.animate({paddingTop: '10px'});
}
});
@
A couple of things here: the render is rebound to [[this]] to make
sure it renders in the context of the view. I found that that was not
always happening. Note the use of [[reduce]], a nifty method from
[[underscore.js]] that allows you to build a result out an array using
an anonymous function. This reduce, obviously, sums up the total
quantity of items in the cart. Also, jQuery enthusiasts could learn
(I certainly did!) from the [[.find()]] and [[.end()]] methods, which
push a child object onto the stack to be animated, and then pop it off
after the operation has been applied.
The biggest thing left is the [[ProductView]]. It's skeleton looks
like this:
<<product view>>=
var ProductView = Backbone.View.extend({
el: $('#main'),
itemTemplate: $("#itemTmpl").template(),
<<product events>>
initialize: function(options) {
this.cart = options.cart;
},
<<update product>>
<<render product>>
});
@
First, we find the element we're going to work with, and the template.
I expect the ProductView to be where we'll add items to the cart, so
the initializer here expects to have a handle on the cart.
And the template:
<<product template>>=
<script id="itemTmpl" type="text/x-jquery-tmpl">
<div class="item-detail">
<div class="item-image"><img src="${attributes.large_image}" alt="${attributes.title}" /></div>
<div class="item-info">
<div class="item-artist">${attributes.artist}</div>
<div class="item-title">${attributes.title}</div>
<div class="item-price">$${attributes.price}</div>
<div class="item-form">
<form action="#/cart" method="post">
<input type="hidden" name="item_id" value="${cid}" />
<p>
<label>Quantity:</label>
<input type="text" size="2" name="quantity" value="1" class="uqf" />
</p>
<p><input type="submit" value="Add to Cart" class="uq" /></p>
</form>
</div>
<div class="item-link"><a href="${attributes.url}">Buy this item on Amazon</a></div>
<div class="back-link"><a href="#">&laquo; Back to Items</a></div>
</div>
</div>
</script>
@
One extra item: note the octothorpe used as the target link for
``Home''. I kept thinking an empty link or just ``/'' would be
appropriate, but no, it's an octothorpe.
Rendering the product is not difficult:
<<render product>>=
render: function() {
var sg = this;
this.el.fadeOut('fast', function() {
sg.el.empty();
$.tmpl(sg.itemTemplate, sg.model).appendTo(sg.el);
sg.el.fadeIn('fast');
});
return this;
}
@
That looks familiar.
Updating the product, however, is a whole 'nother story. Note that
each product has a form associated with it. We need to intercept any
form update events and manipulate our shopping cart. We have two
objects that can do that: the input field, and the submit button. I
need to intercept those events:
<<product events>>=
events: {
"keypress .uqf" : "updateOnEnter",
"click .uq" : "update",
},
@
Backbone uses a curious definition of an event with an ``event
selector'', followed by a target method of the View class. Backbone
is also limited about what events can be used here, as the following
events cannot be wrapped by jQuery's delegate method and do not work:
``focus'', ``blur'', ``change'', ``submit'', and ``reset''.
The update then becomes straightforward. We're in a view for a
specific product; we must see if the customer has a [[CartItem]] for
that product in the [[Cart]], and add or update it as needed. Like
so:
<<update product>>=
update: function(e) {
e.preventDefault();
var cart_item = this.cart.getByPid(this.model.cid);
if (_.isUndefined(cart_item)) {
cart_item = new CartItem({product: this.model, quantity: 0});
this.cart.add(cart_item, {silent: true});
}
cart_item.update(parseInt($('.uqf').val()));
},
updateOnEnter: function(e) {
if (e.keyCode == 13) {
return this.update(e);
}
},
@
We [[preventDefault]] to keep the traditional meaning of the submit
button from triggering. When the [[CartItem]] is updated, it triggers
a ``change'' event, and the [[CartView]] will update itself
automatically. I added the ``silent'' option to keep the ``change''
event from triggering twice when adding a new [[CartItem]] to the
[[Cart]].
And now I'm down to one last thing. I haven't defined that product
render call in the application controller. The one thing I don't want
to do is have [[ProductViews]] for every product, if I don't need
them. So I want to build them as-needed, but keep them, and associate
them with the local [[Product]], so they can be recalled whenever we
want. The underscore function [[isUndefined]] is excellent for this.
<<product render call>>=
item: function(id) {
if (_.isUndefined(this._products.getByCid(id)._view)) {
this._products.getByCid(id)._view = new ProductView({model: this._products.getByCid(id),
cart: this._cart});
}
this._products.getByCid(id)._view.render();
}
@
And now my store looks like
<<store.js>>=
<<product models>>
<<shopping cart models>>
<<shopping cart view>>
<<product view>>
<<index view>>
<<workspace>>
@
As always, this code is available at github.
\end{document}

View File

@ -2,9 +2,11 @@
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head> <head>
<meta name="generator" content="HTML Tidy for Linux/x86 (vers 25 March 2009), see www.w3.org" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>The Backbone Store</title>
<link rel="stylesheet" href="jsonstore.css" type="text/css" media="screen" charset="utf-8" />
<script id="indexTmpl" type="text/x-jquery-tmpl"> <script id="indexTmpl" type="text/x-jquery-tmpl">
<div class="item"> <div class="item">
<div class="item-image"> <div class="item-image">
@ -39,16 +41,12 @@
</div> </div>
</script> </script>
<title>
The Backbone Store
</title>
<link rel="stylesheet" href="jsonstore.css" type="text/css" media="screen" charset="utf-8" />
</head> </head>
<body> <body>
<div id="container"> <div id="container">
<div id="header"> <div id="header">
<h1> <h1>
The JSON Store The Backbone Store
</h1> </h1>
<div class="cart-info"> <div class="cart-info">
@ -63,7 +61,6 @@
<script src="jquery.tmpl.min.js" type="text/javascript"></script> <script src="jquery.tmpl.min.js" type="text/javascript"></script>
<script src="underscore.js" type="text/javascript"></script> <script src="underscore.js" type="text/javascript"></script>
<script src="backbone.js" type="text/javascript"></script> <script src="backbone.js" type="text/javascript"></script>
<script src="backbone-localstorage.js" type="text/javascript"></script>
<script src="store.js" type="text/javascript"></script> <script src="store.js" type="text/javascript"></script>
</body> </body>
</html> </html>

View File

@ -7,7 +7,6 @@ var ProductCollection = Backbone.Collection.extend({
} }
}); });
var CartItem = Backbone.Model.extend({ var CartItem = Backbone.Model.extend({
update: function(amount) { update: function(amount) {
this.set({'quantity': this.get('quantity') + amount}); this.set({'quantity': this.get('quantity') + amount});
@ -22,7 +21,6 @@ var Cart = Backbone.Collection.extend({
}, },
}); });
var CartView = Backbone.View.extend({ var CartView = Backbone.View.extend({
el: $('.cart-info'), el: $('.cart-info'),
@ -39,7 +37,6 @@ var CartView = Backbone.View.extend({
} }
}); });
var ProductView = Backbone.View.extend({ var ProductView = Backbone.View.extend({
el: $('#main'), el: $('#main'),
itemTemplate: $("#itemTmpl").template(), itemTemplate: $("#itemTmpl").template(),
@ -97,8 +94,6 @@ var IndexView = Backbone.View.extend({
}); });
var Workspace = Backbone.Controller.extend({ var Workspace = Backbone.Controller.extend({
_index: null, _index: null,
_products: null, _products: null,