Client-side templating with Handlebars.js
Posted by Tom on 2011-11-30 22:43
So back to data molestation in Javascript.
Now we have our post-processed data it's time to display it to the user.
Templating in Javascript seems to be in vogue at the moment, so there's a pretty decent selection to choose from. Originally I was using jquery-tmpl, but since that project got abandoned in favour of something more awesomer it was time to have a look at alternatives. I initially fell upon Mustache (to be honest, mainly because of the name) but founds myself butting up against the stringently 'logic-less' approach. I guess if you are stricter with your view-models it's less of an issue, but I just found it a little restrictive. Handlebars is an evolution of Mustache from Yehuda Katz (of Rails fame), and appears to scratch the itches that Mustache leaves me with in addition to being a superset of the Mustache syntax. Also, the name is still cool.
A quick note, before we get started. On my Mustache-based travels I stumbled across ICanHaz.js, which simplifies the process and has some really nice ideas. For example, on page load it grabs all of the templates and adds them to a globally available collection for ease of use. This becomes even more useful in Handlebars since it requires an additional compilation step. I ended up writing something very similar but considerably more primitive, and here it is:
var BandleHars = {
templateCache : {},
init : function() {
var templateList = $('script[type="text/html"]');
templateList.each(function(index, value) { BandleHars.templateCache[value.id] = Handlebars.compile($(value).html()); });
templateList.remove();
},
render : function(templateName, data) {
return this.templateCache[templateName](data)
}
};
$(function() { BandleHars.init(); });
Right, let's get back to templating. First we need our data. I used a grab of the first few posts of this very site, pretty much as it's dumped out by System.Web.Script.Serialization.JavaScriptSerializer.
var data = [
{
"Comments" : [
{ "Body" : ". . . the comment system.",
"CommentId" : 1,
"DateCreated" : "/Date(1248627151247)/",
"GravatarUrl" : "http://www.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e?d=identicon&s=64",
"Heading" : "Testing . . .",
"Poster" : "Tom",
"Website" : "http://monochromacy.net/"
},
{ "Body" : "n/t",
"CommentId" : 2,
"DateCreated" : "/Date(1254127922440)/",
"GravatarUrl" : "http://www.gravatar.com/avatar/d41d8cd98f00b204e9800998ecf8427e?d=identicon&s=64",
"Heading" : "Notification Test",
"Poster" : "Test",
"Website" : ""
}
],
"DateCreated" : "/Date(1248624603550)/",
"DateLastTouched" : "/Date(1248624603550)/",
"Heading" : "Oh hai!",
"PostId" : 1,
"Slug" : "Oh-hai",
"Summary" : "This weblog will be largely concerned with ASP.NET, C# and ...",
"Tags" : [ "Waffle" ]
},
/* SNIP */
{
"Comments" : [ ],
"DateCreated" : "/Date(1261657386630)/",
"DateLastTouched" : "/Date(1261657386630)/",
"Heading" : "Introducing Compares Favourably",
"PostId" : 8,
"Slug" : "Introducing-Compares-Favourably",
"Summary" : "So I made a database schema comparison tool for MSSQL 2005.\nCompares Favourably\n<p ...",
"Tags" : [ "ComparesFavourably", "dotNet", "Projects" ]
},
];
var dataSet = new DataContainer(data, 3);
dataSet.update = function()
{
// We'll come back to this . . .
};
dataSet.sortProperty = 'DateCreated';
$(function() { dataSet.update(); });
Here I'm setting up the DataContainer with three items to a page, sorted on the created data and then running the (currently empty) update function once the DOM has been loaded. Now it's time for some markup to display and manipulate our data.
<label>Paging</label>
<ul class="horizontal">
<li><a href="#" onclick="return dataSet.pageDown();">Page down</a></li>
<li><a href="#" onclick="return dataSet.pageUp();">Page up</a></li>
</ul>
<label>Sort by</label>
<ul class="horizontal">
<li><a href="#" onclick="return dataSet.setSortProperty('Heading');">Heading</a></li>
<li><a href="#" onclick="return dataSet.setSortProperty('Summary');">Summary</a></li>
<li><a href="#" onclick="return dataSet.setSortProperty('Slug');">Slug</a></li>
<li><a href="#" onclick="return dataSet.setSortProperty('DateCreated');">Date Created</a></li>
</ul>
Nice and simple. This will page the data up and down, allow us to change the sort criteria, and includes some bonus hey-that's-not-what-label-elements-are-for abuse. Sue me. I figure a tag filter would make sense in this context and since this is dependant on the data it will need to be templated. First we need a container for our filter buttons:
<label>Tags</label>
<ul class="horizontal" id="tagsFilter"></ul>
And now a template for creating the filter links:
<script id="tagsFilterTemplate" type="text/html">
{{#each data}}<li><a href="#" onclick="return dataSet.addFilter('Tags', '{{this}}');">{{this}}</a></li>{{/each}}
<li><a href="#" onclick="return dataSet.removeFilter('Tags');">remove</a></li>
</script>
The above Bandlehars code (I hate that name already) will fetch and compile this template on body load, so now we just need to add this to our DataContainer update function to include the template rendering:
dataSet.update = function()
{
// There's still more to come!
$('#tagsFilter').html(Bandlehars.render('tagsFilterTemplate', { data : dataSet.getPropertyValues('Tags') }));
};
Now, once the page is loaded and then whenever it is filtered, the list of available tags will be updated. This means we can drill down further using additional filters. Worth noting is that we're wrapping our data in another object, since Handlebars doesn't accept raw arrays as a data context. So that's the filters sorted. But we still can't see the data itself. It's time for another container and another template.
<div id="data"></div>
<script id="dataTemplate" type="text/html">
{{#each data}}
<div>
<h2><a href="http://monochromacy.net/Post/{{Slug}}.aspx">{{Heading}}</a></h2>
<p><em>{{DateCreated}}</em></p>
<p>{{Summary}}</p>
<label>Tags</label>
<ul class="horizontal">{{#each Tags}}<li><a href="#" onclick="return dataSet.addFilter('Tags', '{{this}}');">{{this}}</a></li>{{/each}}</ul>
</div>
{{/each}}
</script>
Now to add it to our DataContainer update function.
dataSet.update = function()
{
$('#data').html(Bandlehars.render('dataTemplate', { data : dataSet.getData() }));
$('#tagsFilter').html(Bandlehars.render('tagsFilterTemplate', { data : dataSet.getPropertyValues('Tags') }));
};
Well it's a start. What's next? How about comments.
<script id="dataTemplate" type="text/html">
{{#each data}}
<div>
<h2><a href="http://monochromacy.net/Post/{{Slug}}.aspx">{{Heading}}</a></h2>
<p><em>{{DateCreated}}</em></p>
<p>{{Summary}}</p>
<label>Tags</label>
<ul class="horizontal">{{#each Tags}}<li><a href="#" onclick="return dataSet.addFilter('Tags', '{{this}}');">{{this}}</a></li>{{/each}}</ul>
</div>
<br />
{{#if Comments.length}}
<div class="comments">
<h4>Comments</h4>
<ul>
{{#each Comments}}
<li>{{Heading}} - {{Body}} - {{Poster}}</li>
{{/each}}
</ul>
</div>
{{/if}}
<hr />
{{/each}}
</script>
As you can see, we've got a conditional part of the template in there. This is the kind of thing that Mustache consciously avoids, but I personally find quite useful.
A note about line 11 above. The Handlebars site says that the if block helper evaluates an empty array to false, and I also uncovered a pull request on Github mentioning this very thing that was merged on June 27. However, the latest download is the v1 beta-3 which was packaged on June 2, hence resorting to the .length trick. It looks like this won't be necessary after the next release.
Finally, .NET has done something weird and unseemly to our date values, so we need some way to tweak those back into shape. Thankfully another feature of Handlebars is the ability to add custom helpers.
Handlebars.registerHelper("dotNetDate", function(context) {
var m = context.match(/^\/Date\((\d+)\)\/$/);
var date = null;
if (m)
date = new Date(parseInt(m[1]));
return date.toLocaleDateString();
});
And we can invoke it using this:
{{dotNetDate DateCreated}}
Giving us our final version, which you can see running here:
DataContainer + Handlebars.js Example
The downsides? Unfortunately this is an SEO black hole since all of the real content is injected in via Javascript at page load. You'll need to render this on the server-side in order to receive any search engine attention at all. That said, there's Mustache implementations in a tonne of viable server-side languages and while the few changes to Handlebars almost certainly increase the complexity . . . hmmm. Time for another weekend project?