The following is a VERY rough draft of an article I am working on for Alex MacCaw's @maccman's Book. It is very rough, but even now a worthwhile read. Suggestions / comments are very welcome! Please help me :-)
DA: Doing some additions/clarifications. Minor wording changes are not marked, those sections or large items I added I'll try to mark with "DA."
JavaScriptMVC (JMVC) is an open-source jQuery-based JavaScript framework. It is nearly a comprehensive (holistic) front-end development framework, packaging utilities for testing, dependency management, documentation, and a host of useful jQuery plugins.
Yet every part of JavaScriptMVC can be used without every other part, making the library lightweight. Its Class, Model, View, and Controller combined are only 7k minified and compressed, yet even they can be used independently. JavaScriptMVC's independence lets you start small and scale to meet the challenges of the most complex applications on the web.
This chapter covers only JavaScriptMVC's
-
$.Class
- JavaScript based class system -
$.Model
- traditional model layer -
$.View
- client side template system -
$.Controller
- jQuery widget factory
JavaScriptMVC's naming conventions deviate slightly from the traditional Model-View-Controller design pattern. $.Controller is used to create traditional view controls, like pagination buttons and list, as well as traditional controllers, which coordinate between the traditional views and models.
JavaScriptMVC can be used as a single download that includes the entire framework. But since this chapter covers only the MVC parts, go to the download builder, check Controller, Model, and View's EJS templates and click download.
The download will come with minified and unminified versions of jQuery and the plugins you selected. Load these with script tags in your page:
<script type='text/javascript' src='jquery-1.6.1.js'></script>
<script type='text/javascript' src='jquerymx-1.0.custom.js'></script>
JMVC's Controller and Model inherit from its Class helper - $.Class. To create a class, call $.Class(NAME, [classProperties, ] instanceProperties])
.
DA: Note that as the classProperty is optional, if only two arguments are passed to Class, it assumes you are setting instanceProperties.
$.Class("Animal",{
breathe : function(){
console.log('breathe');
}
});
In the example above, instances of Animal have a breathe()
method. We can create a new Animal
instance and call breathe()
on it like:
var man = new Animal();
man.breathe();
If you want to create a sub-class, simply call the the base class with the sub-class's name and properties:
Animal("Dog",{
wag : function(){
console.log('wag');
}
})
var dog = new Dog;
dog.wag();
dog.breathe();
When a new class instance is created, it calls the class's init
method with the arguments passed to the constructor function:
$.Class('Person',{
init : function(name){
this.name = name;
},
speak : function(){
return "I am "+this.name+".";
}
});
var payal = new Person("Payal");
assertEqual( payal.speak() , 'I am Payal.' );
Call base methods with this._super
. The following overwrites person
to provide a more 'classy' greating:
Person("ClassyPerson", {
speak : function(){
return "Salutations, "+this._super();
}
});
var fancypants = new ClassyPerson("Mr. Fancy");
assertEquals( fancypants.speak() , 'Salutations, I am Mr. Fancy.')
Class's callback method returns a function that has 'this' set appropriately (similar to $.proxy). The following creates a clicky class that counts how many times it was clicked:
$.Class("Clicky",{
init : function(){
this.clickCount = 0;
},
clicked: function(){
this.clickCount++;
},
listen: function(el){
el.click( this.callback('clicked') );
}
})
var clicky = new Clicky();
clicky.listen( $('#foo') );
clicky.listen( $('#bar') ) ;
Class lets you define inheritable static properties and methods. The following allows us to retrieve a person instance from the server by calling Person.findOne(ID, success(person) )
. Success is called back with an instance of Person, which has the speak
method.
$.Class("Person",{
findOne : function(id, success){
$.get('/person/'+id, function(attrs){
success( new Person( attrs ) );
},'json')
}
},{
init : function(attrs){
$.extend(this, attrs)
},
speak : function(){
return "I am "+this.name+".";
}
})
Person.findOne(5, function(person){
assertEqual( person.speak(), "I am Payal." );
})
Class provides namespacing and access to the name of the class and namespace object:
$.Class("Jupiter.Person");
Jupiter.Person.shortName; //-> 'Person'
Jupiter.Person.fullName; //-> 'Jupiter.Person'
Jupiter.Person.namespace; //-> Jupiter
var person = new Jupiter.Person();
person.Class.shortName; //-> 'Person'
Putting it all together, we can make a basic ORM-style model layer. Just by inheriting from Model, we can request data from REST services and get it back wrapped in instances of the inheriting Model.
$.Class("Model",{
// static method to query the backend
findOne : function(id, success){
$.get('/'+this.fullName.toLowerCase()+'/'+id,
// 'this' refers to the current Class which allows us to...
this.callback(function(attrs){
// instantiate a new instance of it which invokes the init() method
success( new this( attrs ) );
})
},'json')
}
},{
// uses jQuery extend() to add the properties retrieved from the backend
// to the instance
init : function(attrs){
$.extend(this, attrs)
}
})
// Extends "Model" and adds the 'speak' method
Model("Person",{
speak : function(){
return "I am "+this.name+".";
}
});
// Invokes the static 'findOne' method, passing the id to search for and
// the 'success' method to invoke when the data is returned which, in the
// static findOne() method instantiates a new 'person' object
Person.findOne(5, function(person){
alert( person.speak() );
});
// Extends "Model" but does not add any methods
Model("Task")
// The "Task" class inherits the 'findOne' method
Task.findOne(7,function(task){
alert(task.name);
})
This is similar to how JavaScriptMVC's model layer works.
JavaScriptMVC's model and its associated plugins provide lots of tools around organizing model data such as validations, associations, lists and more. But the core functionality is centered around service encapsulation, type conversion, and events.
Of absolute importance to a model layer is the ability to get and set properties on the modeled data and listen for changes on a model instance. This is the Observer pattern and lies at the heart of the MVC approach - views listen to changes in the model.
Fortunately, JavaScriptMVC makes it easy to make any data observable. A great example is pagination. It's very common that multiple pagination controls exist on the page. For example, one control might provide next and previous page buttons. Another control might detail the items the current page is viewing (ex "Showing items 1-20"). All pagination controls need the exact same data:
- offset - the index of the first item to display
- limit - the number of items to display
- count - the total number of items
We can model this data with JavaScriptMVC's $.Model like:
var paginate = new $.Model({
offset: 0,
limit: 20,
count: 200
});
The paginate variable is now observable. We can pass it to pagination controls that can read from, write to, and listen for property changes. You can read properties like normal or using the model.attr(NAME)
method:
assertEqual( paginate.offset, 0 );
assertEqual( paginate.attr('limit') , 20 );
If we clicked the next button, we need to increment the offset. Change property values with model.attr(NAME, VALUE)
. The following moves the offset to the next page:
paginate.attr('offset',20);
When paginate's state is changed by one control, the other controls need to be notified. You can bind to a specific attribute change with model.bind(ATTR, success( ev, newVal ) )
and update the control:
paginate.bind('offset', function(ev, newVal){
$('#details').text( 'Showing items ' + (newVal+1 )+ '-' + this.count )
})
You can also listen to any attribute change by binding to the 'updated.attr'
event:
paginate.bind('updated.attr', function(ev, newVal){
$('#details').text( 'Showing items ' + (newVal+1 )+ '-' + this.count )
})
DA: Note the above code is incorrect -- binding to 'update.attr' returns the name of the property that was changed into 'newVal', not the new value it was changed to. You can access the value that was changed by using 'this[newVal].' So, the above should work if 'newVal' is used as follows:
paginate.bind('updated.attr', function(ev, newVal){
$('#details').text( 'Showing items ' + (this[newVal]+1 )+ '-' + this.count )
})
The following is a next-previous jQuery plugin that accepts paginate data:
$.fn.nextPrev = function(paginate){
// DA: bind events for the next and previous buttons
this.delegate('.next','click', function(){
var nextOffset = paginate.offset+paginate.limit;
if( nextOffset < paginate.count){
paginate.attr('offset', nextOffset );
}
})
this.delegate('.prev','click', function(){
var nextOffset = paginate.offset-paginate.limit;
if( 0 < paginate.offset ){
paginate.attr('offset', Math.max(0, nextOffset) );
}
});
// DA: get a references to the plug-in
var self = this;
// DA: if any attribute changes, verify the state of the next and prev buttons
paginate.bind('updated.attr', function(){
var next = self.find('.next'),
prev = self.find('.prev');
// DA: 'this' refers to the pagination model
if( this.offset == 0 ){
prev.removeClass('enabled');
} else {
prev.removeClass('disabled');
}
if( this.offset > this.count - this.limit ){
next.removeClass('enabled');
} else {
next.removeClass('disabled');
}
})
};
There are a few problems with this plugin. First, if the control is removed from the page, it is not unbinding itself from paginate. We'll address this when we discuss controllers.
Second, the logic protecting a negative offset or offset above the total count is done in the plugin. This logic should be done in the model. To fix this problem, we'll need to add additional constraints to limit what values limit, offset, and count can be. We'll need to create a pagination class.
JavaScriptMVC's model inherits from $.Class. Thus, you create a model class by inheriting from $.Model(NAME, [STATIC,] PROTOTYPE)
:
$.Model('Paginate',{
staticProperty: 'foo'
},{
prototypeProperty: 'bar'
})
There are a few ways to make the Paginate model more useful. First, by adding setter methods, we can limit what values count and offset can be set to.
The Model class views any prototype (instance) methods that are given a name like setNAME
as being 'setter' methods. Setter methods get called automatically when model.attr(NAME, val)
is invoked and are passed three arguments: the value (val) passed to model.attr(NAME, val)
and success and error callbacks. Typically, setter methods should return the value that should be set on the model instance or call the error callback with an error message. The success callback is used for asynchronous setters.
The following paginate model uses setters to prevent negative count property values and makes sure that the offset property does not exceed the count property by adding setCount
and setOffset
instance methods.
$.Model('Paginate',{
setCount : function(newCount, success, error){
return newCount < 0 ? 0 : newCount;
},
setOffset : function(newOffset, success, error){
// DA: The Math.min() function returns the minimum of either the newOffset
// or the value of the count property.
// The '!isNan()' is just used to ensure the count property
// actually contains a valid number. It is removed in a latter example
// when defaults have been set for the properties.
return newOffset < 0 ? 0 : Math.min(newOffset, ( !isNaN(this.count - 1) ? this.count : Infinity ) )
}
})
Now the nextPrev plugin can set offset with reckless abandon because the setters defined above are called automatically by the 'attr()' method:
this.delegate('.next','click', function(){
paginate.attr('offset', paginate.offset+paginate.limit);
})
this.delegate('.prev','click', function(){
paginate.attr('offset', paginate.offset-paginate.limit );
});
We can add default values to Paginate instances by setting the static defaults
property. When a new paginate instance is created, if no value is provided, it initializes with the default value.
$.Model('Paginate',{
defaults : {
count: Infinity,
offset: 0,
limit: 100
}
},{
setCount : function(newCount, success, error){ ... },
setOffset : function(newOffset, success, error){ ... }
})
var paginate = new Paginate({count: 500});
assertEqual(paginate.limit, 100);
assertEqual(paginate.count, 500);
This is getting sexy, but the Paginate model can make it even easier to move to the next and previous page and know if it's possible by adding helper methods.
Helper methods are prototype (instance) methods that help set or get useful data on model instances. The following, completed, Paginate model includes a next
and prev
method that will move to the next and previous page if possible. It also provides a canNext
and canPrev
method that returns if the instance can move to the next page or not.
$.Model('Paginate',{
defaults : {
count: Infinity,
offset: 0,
limit: 100
}
},{
setCount : function( newCount ){
return Math.max(0, newCount );
},
setOffset : function( newOffset ){
return Math.max( 0 , Math.min(newOffset, this.count ) )
},
next : function(){
this.attr('offset', this.offset+this.limit);
},
prev : function(){
this.attr('offset', this.offset - this.limit )
},
canNext : function(){
return this.offset > this.count - this.limit
},
canPrev : function(){
return this.offset > 0
}
})
Thus, our jQuery widget becomes much more refined:
$.fn.nextPrev = function(paginate){
this.delegate('.next','click', function(){
paginate.attr('offset', paginate.offset+paginate.limit);
})
this.delegate('.prev','click', function(){
paginate.attr('offset', paginate.offset-paginate.limit );
});
var self = this;
paginate.bind('updated.attr', function(){
self.find('.prev')[paginate.canPrev() ? 'addClass' : 'removeClass']('enabled')
self.find('.next')[paginate.canNext() ? 'addClass' : 'removeClass']('enabled');
})
};
We've just seen how
A REST service uses urls and the HTTP verbs POST, GET, PUT, DELETE to create, retrieve, update, and delete data respectively. For example, a tasks service that allowed you to create, retrieve, update and delete tasks might look like:
ACTION | VERB | URL | BODY | RESPONSE |
---|---|---|---|---|
Create a task | POST | /tasks | name=do the dishes |
|
Get a task | GET | /task/2 |
|
|
Get tasks | GET | /tasks |
|
|
Update a task | PUT | /task/2 | name=take out recycling |
|
Delete a task | DELETE | /task/2 |
|
TODO: We can label the urls
The following connects to task services, letting us create, retrieve, update and delete tasks from the server:
$.Model("Task",{
create : "POST /tasks.json",
findOne : "GET /tasks/{id}.json",
findAll : "GET /tasks.json",
update : "PUT /tasks/{id}.json",
destroy : "DELETE /tasks/{id}.json"
},{ });
The following table details the general format for how to use the task model to CRUD tasks. Following this will be an example of how to use invoke these methods using a ColdFusion component backend.
ACTION | CODE | DESCRIPTION |
---|---|---|
Create a task |
|
To create an instance of a model on the server, first create an instance with Save checks if the task has an id. In this case it does not have an id so save automatically invokes the static create action passing it the task's attributes. Save takes two parameters:
Save returns a deferred that resolves to the created task. |
Get a task |
|
Retrieves a single task from the server. It takes three parameters:
findOne returns a deferred that resolves to the task. |
Get tasks |
|
Retrieves an array of tasks from the server. It takes three parameters:
|
Update a task |
|
To update the server, first change the attributes of a model instance with Save takes the same arguments and returns the same deferred as the create task case. |
Destroy a task |
|
Destroys a task on the server. Destroy takes two parameters:
|
The Task
model has essentially become a contract to our services!
Following is an example using a Model bound to a backend ColdFusion component.
/**
* @class Atasker.Models.Tasker
* @parent index
* @inherits jQuery.Model
* Wraps backend tasker services.
*/
$.Model('Atasker.Models.Tasker',
/* @Static */
{
findAll: "TaskerService.cfc?method=findall",
findOne : "TaskerService.cfc?method=get&id={id}",
create : "TaskerService.cfc?method=create&name={name}",
update : "TaskerService.cfc?method=update&id={id}&name={name}",
destroy : "TaskerService.cfc?method=delete&id={id}"
},
/* @Prototype */
{});
// Create a new Tasker and persist it to the database
t = new Atasker.Models.Tasker( { name:"Get the food shopping done!" } )
// Save looks at the data, if there is no id property, it invokes the state create action;
// if there is an id property, it invokes the static update action
.save(
success, // callback when the query succeeds
error // callback for an error
);
// Tasker callbacks
function success( tasker, data ) {
console.log( tasker );
console.log( data );
}
function error ( jqXHR ){
console.log('Houston we have a problem!!!');
console.log( jqXHR );
}
The save method handles several things for us automatically:
-
Based on the presence or absence of an id property in the current object, it decides whether to invoke the static create or static update actions.
-
If the server request completes successfully, it updates the current object with the information from the server, updating existing properties and adding new properties as needed.
Did you notice how the server responded with createdAt values as numbers like 1303173531164
. This number is actually April 18th, 2011. Instead of getting a number back from task.createdAt
, it would be much more useful if it returns a JavaScript date created with new Date(1303173531164)
. We could do this with a setCreatedAt
setter. But, if we have lots of date types, this will quickly get repetitive.
To make this easy, $.Model lets you define the type of an attribute and a converter function for those types. Set the type of attributes on the static attributes
object and converter methods on the static convert
object.
$.Model('Task',{
attributes : {
createdAt : 'date'
},
convert : {
date : function(date){
return typeof date == 'number' ? new Date(date) : date;
}
}
},{});
Task now converts createdAt to a Date type. To list the year of each task, write:
Task.findAll({}, function(tasks){
$.each(tasks, function(){
console.log( "Year = "+this.createdAt.fullYear() )
})
});
Model publishes events when an instance has been created, updated, or destroyed. You can listen to these events globally on the Model or on an individual model instance. Use MODEL.bind(EVENT, callback( ev, instance ) )
to listen for created, updated, or destroyed events.
Lets say we wanted to know when a task is created and add it to the page. After it's been added to the page, we'll listen for updates on that task to make sure we are showing its name correctly. We can do that like: (DA: Assumedly, the 'html(todo.name)' is a typo and should read 'html(task.name)' or the argument passed to the callback should be 'todo.')
2/26/12: DA: I tried testing this and the 'created' event bound find, but the instance level binds for 'updated' and 'destroyed' did not seem to work.
Task.bind('created', function(ev, task){
var el = $('<li>').html(todo.name);
el.appendTo($('#todos'));
task.bind('updated', function(){
el.html(this.name)
}).bind('destroyed', function(){
el.remove()
})
})
The content for $.View has been moved here
The content for $.Controller has been moved here.