There's probably hundreds of Javascript MVC frameworks out there. But my favorite, Agility.js, seems to be barely ever mentioned. Agility has served me well in several projects both big and small, so I'm writing a tutorial in the spirit of Step by step from jQuery to Backbone for Agility.
What makes Agility different from other MVC frameworks? In short, the lack of boilerplate and the completeness. Other frameworks involve many, many lines of boilerplate that provide a significant barrier to entry and detract from the readability of the final product. Agility minimizes this, lets you get started fast, and stays out of your way. But it doesn't sacrifice either completeness or maintainability. Agility is feature-rich, including two-way model-view bindings, declarative controller-events, prototypical inheritance, server persistance, and everything else you'd expect from an MVC framework.
In this tutorial, we'll be working with the same initial code as the Backbone.js tutorial, modified slightly to run on JSFiddle.net (I'll be including links to working JSFiddles for each of the steps in this tutorial). This sample app allows the user to enter a status, posts it to the server, and adds it to a list in the DOM.
$(document).ready(function() {
$('#new-status').submit(function(e) {
e.preventDefault();
$.ajax({
url: '/echo/json/',
type: 'POST',
dataType: 'json',
data: {'json': JSON.stringify({ text: $(this).find('textarea').val() })},
success: function(data) {
$('#statuses').append('<li>' + data.text + '</li>');
$(this).find('textarea').val('');
}
});
});
});
We're currently munging together HTML strings to create the <li>
elements in our success
callback. Let's replace this with an Agility object. Unlike some Javascript MVC frameworks that separate models, views, and controllers into separate objects, an Agility object combines all three concepts into a single object. We'll create a prototype called statusItemProto
that represents a single status item:
+var statusItemProto = $$({
+ model: { text: '' },
+ view: { format: '<li data-bind="text" />' }
+});
$(document).ready(function() {
$('#new-status').submit(function(e) {
e.preventDefault();
$.ajax({
url: '/echo/json/',
type: 'POST',
dataType: 'json',
data: {'json': JSON.stringify({ text: $(this).find('textarea').val() })},
success: function(data) {
- $('#statuses').append('<li>' + data.text + '</li>');
+ $$.document.append( $$(statusItemProto, {
+ model: {text: data.text}
+ }), '#statuses');
$(this).find('textarea').val('');
}
});
});
});
Our statusItemProto
defines a model with a single field text
, and a view consisting of a single <li>
tag. With the data-bind
attribute on the <li>
tag, we tell Agility that the contents of the element should be kept in sync with the text
model attribute. Any changes to the model will automatically update the view.
In the success
AJAX callback, we replace the raw jQuery append
call. We first need to construct the statusItemProto
instance to append to the DOM. The Agility factory function $$
takes an optional first argument: another Agility object to use as the prototype. We override the prototype's empty text
model field with the data.text
value. $$.document.append
is the function used to append the new Agility object to the DOM proper. Its second argument is the jQuery selector for the element to append the object to.
Agility comes with a simple and flexible peristance plugin that allows Agility objects to be loaded from and saved to a server. We'll replace the use of jQuery's ajax
with Agility persistance:
var statusItemProto = $$({
model: { text: ''} ,
view: { format: '<li data-bind="text" />' }
});
+statusItemProto.persist($$.adapter.restful);
$(document).ready(function() {
$('#new-status').submit(function(e) {
e.preventDefault();
- $.ajax({
- url: '/echo/json/',
- type: 'POST',
- dataType: 'json',
- data: {'json': JSON.stringify({ text: $(this).find('textarea').val() })},
- success: function(data) {
- $$.document.append( $$(statusItemProto, {
- model: {text: data.text}
- }), '#statuses');
- $(this).find('textarea').val('');
- }
- });
+ var item = $$(statusItemProto, {
+ model: {text: $(this).find('textarea').val()}
+ });
+ item.bind('persist:save:success', function(){
+ $$.document.append(item, '#statuses');
+ $(this).find('textarea').val('');
+ });
+ item.save();
});
});
The first step is to declare an Agility object as persistable with a call to the persist
method. We'll use the built-in $$.adapter.restful
adapter to interact with a RESTful Web service. In a real application you'll need to pass a second parameter to the persist
method to define options like the base URL and collection (see the documentation).
We then need to break up the $$.document.append
call, because we need to construct the status item before sending it to the server, but should only append it to the DOM when it is successfully sent. We construct the item as normal, then bind an event handler to the persist:save:success
event. Agility defines a number of Agility events, special events that are dispatched by the framework. persist:save:success
and persist:save:error
are analogous to the success
and error
callbacks in jQuery's ajax
, so we'll move all the code from success
there. Finally, we call item.save()
to start the process of saving it.
We're currently binding the form's submit
event imperatively in document.ready
, and our entire codebase relies on a specific arrangement of DOM elements that isn't specified in the code at all. Let's fix these problems by continuing to move code into Agility objects. We'll create a new statusForm
object that packages together the DOM structure of our status form and the its events:
var statusItemProto = $$({
model: { text: ''} ,
view: { format: '<li data-bind="text" />' }
});
statusItemProto.persist($$.adapter.restful);
+var statusForm = $$({
+ view: { format: '<form id="new-status"><textarea></textarea><input type="submit"><div id="statuses"></div></form>'},
+ controller: {
+ 'submit &': function(e){
+ var _this = this;
+ e.preventDefault();
+
+ var item = $$(statusItemProto, {
+ model: {text: _this.view.$('textarea').val()}
+ });
+ item.bind('persist:save:success', function(){
+ $$.document.append(item, '#statuses');
+ _this.view.$('textarea').val('');
+ });
+ item.save();
+ }
+ }
+});
-$(document).ready(function() {
- $('#new-status').submit(function(e) {
- e.preventDefault();
- var item = $$(statusItemProto, {
- model: {text: $(this).find('textarea').val()}
- });
- item.bind('persist:save:success', function(){
- $$.document.append(item, '#statuses');
- $(this).find('textarea').val('');
- });
- item.save();
- });
-});
+$(document).ready(function() {
+ $$.document.append(statusForm);
+});
The statusForm
object doesn't need a model because there's only ever one instance of it and nothing about it changes dynamically. We'll define a view for it that contains all of our app's HTML. For small amounts of HTML like this, embedding it directly into the Javascript file is simple and convenient, though for larger amounts it's advisable to place it elsewhere, like in a non-executable <script>
tag, and reference it indirectly.
We come to the new part of statusForm
, the controller. An Agility controller is an object that maps event strings to the handlers that should be called when the events occur. Controller methods are automatically proxied to their owner object, so this
works correctly in the function bodies. DOM events are bound with a space-separated event string. The first component, in our case submit
, is the jQuery event name. The second is a jQuery selector for the elements to which the event should be bound. The special selector &
refers to the top-level element in the Agility object, in this case <form>
.
Before, we could use $(this)
to get the form element being submitted, because jQuery proxied the event handler to the element. However, Agility proxies the handler to the Agility object, so to get to the elements we need to use this.view.$()
. This function, called with no arguments, returns the top-level element in the object, wrapped by jQuery. With an argument, it looks for elements matching the given selector in the object; this.view.$(sel)
is essentially a shortcut for this.view.$().find(sel)
.
There's one final issue concerning our use of this
. Agility proxies this
in the submit &
event handler, but not in the persist:save:success
handler that is bound later. We need access to the statusForm
in that handler, so we need to store it in _this
beforehand.
Our document.ready
function is now reduced to a single statement: adding statusForm
to the DOM.
I said before that statusForm
doesn't need a model because nothing about it changes. But that's not quite true: the status being entered by the user is part of the form, and it changes as the user is typing. This means if we make it a model field, we can take advantage of Agility's two-way model binding to avoid having to manually select the textarea
and call its val()
in jQuery.
var statusItemProto = $$({
model: { text: ''} ,
view: { format: '<li data-bind="text" />' }
});
statusItemProto.persist($$.adapter.restful);
var statusForm = $$({
- view: { format: '<form id="new-status"><textarea></textarea><input type="submit"><div id="statuses"></div></form>'},
+ model: { enteredText: ''},
+ view: { format: '<form id="new-status"><textarea data-bind="enteredText"></textarea><input type="submit"><div id="sta
controller: {
'submit &': function(e){
var _this = this;
e.preventDefault();
var item = $$(statusItemProto, {
- model: {text: _this.view.$('textarea').val()}
+ model: {text: _this.model.get('enteredText')}
});
item.bind('persist:save:success', function(){
$$.document.append(item, '#statuses');
- _this.view.$('textarea').val('');
+ _this.model.set({enteredText: ''});
});
item.save();
}
}
});
$(document).ready(function() {
$$.document.append(statusForm);
});
We added a new enteredText
model field to the status form, and used data-bind
to bind it to the input <textarea>
. Now we can use this.model.get()
to get the entered text, and this.model.set()
to clear it after submission, and Agility will always keep it in sync with the value in the textarea. We could also bind to the Agility event change:enteredText
to get an event every time the text changes, but we don't need that for this application.
We've been using Agility's prototypical inheritance for setting the initial values of model fields. But it's significantly more powerful than that; using inheritence we can also modify the view, add new controller methods, or extend existing methods. Let's take advantage of this to combine the three statements about item
into one:
var statusItemProto = $$({
model: { text: ''} ,
view: { format: '<li data-bind="text" />' }
});
statusItemProto.persist($$.adapter.restful);
var statusForm = $$({
model: { enteredText: ''},
view: { format: '<form id="new-status"><textarea data-bind="enteredText"></textarea><input type="submit"><div id="sta
controller: {
'submit &': function(e){
var _this = this;
e.preventDefault();
- var item = $$(statusItemProto, {
- model: {text: _this.model.get('enteredText')}
- });
- item.bind('persist:save:success', function(){
- $$.document.append(item, '#statuses');
- _this.model.set({enteredText: ''});
- });
- item.save();
+ $$(statusItemProto, {
+ model: {text: _this.model.get('enteredText')},
+ controller: {'persist:save:success': function(){
+ _this.append(this, '#statuses');
+ _this.model.set({enteredText: ''});
+ }}
+ }).save();
}
}
});
$(document).ready(function() {
$$.document.append(statusForm);
});
Instead of using bind
to bind an event after the creation of the object, we just declare the binding when constructing the object. There's no longer an item
variable to close over in the persist:save:success
handler, but since the handler is now a full controller method of the status item, it's automatically proxied to it, so we can just use this
.
One final change we probably should have made earlier: we call _this.append
rather than $$.document.append
to add the new item to the DOM. All Agility objects have an append
method (and also similar prepend
, after
, and before
methods) that adds another object to its container. $$.document
is simply a special Agility object that exists by default and whose view is the <body>
tag. The status item should be appended to the status form so that it can be later accessed by Agility object methods like size()
and each()
, and so it is automatically destroyed if the status form is destroyed.
Here's our final code:
var statusItemProto = $$({
model: { text: ''} ,
view: { format: '<li data-bind="text" />' }
});
statusItemProto.persist($$.adapter.restful);
var statusForm = $$({
model: { enteredText: ''},
view: { format: '<form id="new-status"><textarea data-bind="enteredText"></textarea><input type="submit"><div id="statuses"></div></form>'},
controller: {
'submit &': function(e){
var _this = this;
e.preventDefault();
$$(statusItemProto, {
model: {text: _this.model.get('enteredText')},
controller: {'persist:save:success': function(){
_this.append(this, '#statuses');
_this.model.set({enteredText: ''});
}}
}).save();
}
}
});
$(document).ready(function() {
$$.document.append(statusForm);
});
The code size has increased from 16 lines to 25. This small increase is more than made up for by increased abstraction and maintainability of the codebase. For example:
- We've separated the concerns of the status item and status from each other, so if we wanted to change the markup associated with a status item, we change it
statusItemProto
without having to touchstatusForm
. Or if we want to add behavior to a status item, we can simply add a controller to the status item. - We're binding events declaratively, so if we wanted to get events as the user is typing, perhaps to implement a character counter, we can bind to the Agility model event
change:enteredText
instead of imperatively finding the textarea and binding to its 'change' - We're using Agility's persistance framework instead of hard-coding jQuery ajax, so if we wanted to save to localStorage instead of a server, we could just change
$$.adapter.restful
to$$.adapter.localStorage
- We're using statusForm as an Agility container, so if we wanted to remove all the status items, we can just call
empty()
on it rather than manually selecting and removing all the<li>
elements.
Because of its use of heavily nested objects in the $$
factory function, Agility benefits even more than the average Javascript code in readability from the use of CoffeeScript:
statusItemProto = $$
model:
text: ''
view:
format: '<li data-bind="text" />'
statusItemProto.persist $$.adapter.restful
statusForm = $$
model:
enteredText: ''
view:
format: '<form id="new-status"><textarea data-bind="enteredText"></textarea><input type="submit"><div id="statuses"></div></form>'
controller:
'submit &': (e) ->
_this = this
e.preventDefault()
$$(statusItemProto,
model:
text: _this.model.get('enteredText')
controller:
'persist:save:success': ->
_this.append this, '#statuses'
_this.model.set enteredText: ''
).save()
$(document).ready ->
$$.document.append statusForm
Hopefully this tutorial has helped introduce you to Agility.js. I've covered most of the Agility features, but have only scratched the surface in terms of the interesting ways that they can be combined. Unfortunately there's not much in the way of tutorials and examples online outside of the official site and official documentation. Please let me know if you come across anything else.
Once you understand the high-level concepts, the source code is well-commented and contained in a relatively short single file.
This article was very helpful in learning about Agility.js. Its simplicity is refreshing even compared to Backbone, and so easy to get started. Really appreciate the effort in putting together a complete overview of features.