TODOS:
- show .models() method and hookup
JavaScriptMVC's controllers are many things. They are a jQuery plugin factory. They can be used as a traditional view, making pagination widgets and grid controls. Or, they can be used as a traditional controller, initializing and controllers and hooking them up to models. Mostly, controller's are a really great way of organizing your application's code.
Controllers provide a number of handy features such as:
- jQuery plugin creation
- automatic binding
- default options
- automatic determinism
But controller's most important feature is not obvious to any but the most hard-core JS ninjas. The following code creates a tooltip like widget that displays itself until the document is clicked.
$.fn.tooltip = function(){
var el = this[0];
$(document).click(function(ev){
if(ev.target !== el){
$(el).remove()
}
})
$(el).show();
return this;
})
To use it, you'd add the element to be displayed to the page, and then call tooltip on it like:
$("<div class='tooltip'>Some Info</div>")
.appendTo(document.body)
.tooltip()
But, this code has a problem. Can you spot it? Here's a hint. What if your application is long lived and lots of these tooltip elements are created?
The problem is this code leaks memory! Every tooltip element, and any tooltip child elements, are kept in memory forever. This is because the click handler is not removed from the document and has a closure reference to the element.
This is a frighteningly easy mistake to make. jQuery removes all event handlers from elements that are removed from the page so developers often don't have to worry about unbinding event handlers. But in this case, we bound to something outside the widget's element, the document, and did not unbind the event handler.
But within a Model-View-Controller architecture, Controllers listen to the View and Views listen to the Model. You are constantly listening to events outside the widget's element. For example, the nextPrev
widget from the $.Model
section listens to updates in the paginate model:
paginate.bind('updated.attr', function(){
self.find('.prev')[this.canPrev() ? 'addClass' : 'removeClass']('enabled')
self.find('.next')[this.canNext() ? 'addClass' : 'removeClass']('enabled');
})
But, it doesn't unbind from paginate! Forgetting to remove event handlers is potentially a source of errors. However, both the tooltip and nextPrev would not error. Instead both will silently kill an application's performance. Fortunately, $.Controller makes this easy and organized. We can write tooltip like:
$.Controller('Tooltip',{
init: function(){
this.element.show()
},
"{document} click": function(el, ev){
if(ev.target !== this.element[0]){
this.element.remove()
}
}
})
When the document is clicked and the element is removed from the DOM, $.Controller will automatically unbind the document click handler.
$.Controller can do the same thing for the nextPrev widget binding to the the paginate model:
$.Controller('Nextprev',{
".next click" : function(){
var paginate = this.options.paginate;
paginate.attr('offset', paginate.offset+paginate.limit);
},
".prev click" : function(){
var paginate = this.options.paginate;
paginate.attr('offset', paginate.offset-paginate.limit );
},
"{paginate} updated.attr" : function(ev, paginate){
this.find('.prev')[paginate.canPrev() ? 'addClass' : 'removeClass']('enabled')
this.find('.next')[paginate.canNext() ? 'addClass' : 'removeClass']('enabled');
}
})
// create a nextprev control
$('#pagebuttons').nextprev({ paginate: new Paginate() })
If the element #pagebuttons
is removed from the page, the Nextprev controller instance will automatically unbind from the paginate model.
Now that your appetite for error free code is properly whetted, the following details how $.Controller works.
$.Controller( NAME, classProperties, instanceProperties )
with the name of your controller, static methods, and instance methods. The following is the start of a reusable list widget:
$.Controller("List", {
defaults : {}
},{
init : function(){ },
"li click" : function(){ }
})
When a controller class is created, it creates a jQuery helper method of a similar name. The helper method is primarily use to create new instances of controller on elements in the page. The helper method name is the controller's name underscored, with any periods replaced with underscores. For example, the helper for $.Controller('App.FooBar')
is $(el).app_foo_bar()
.
To create a controller instance, you can call new Controller(element, options)
with a HTMLElment or jQuery-wrapped element and an optional options object to configure the controller. For example:
new List($('ul#tasks'), {model : Task});
You can also use the jQuery helper method to create a List controller instance on the #tasks
element like:
$('ul#tasks').list({model : Task})
When a controller is created, it calls the controller's prototype init method with:
this.element
set to the jQuery-wrapped HTML elementthis.options
set to the options passed to the controller merged with the class'sdefaults
object.
The following updates the List controller to request tasks from the model and render them with an optional template passed to the list:
$.Controller("List", {
defaults : {
template: "items.ejs"
}
},{
init : function(){
this.element.html( this.options.template, this.options.model.findAll() );
},
"li click" : function(){ }
})
We can now configure Lists to render tasks with a template we provide. How flexible!
$('#tasks').list({model: Task, template: "tasks.ejs"});
$('#users').list({model: User, template: "users.ejs"})
If we don't provide a template, List will default to using items.ejs.
As mentioned in $.Controller's introduction, it's most powerful feature is it's ability to bind and unbind event handlers.
When a controller is created, it looks for action methods. Action methods are methods that look like event handlers. For example, "li click"
. These actions are bound using jQuery.bind
or jQuery.delegate
. When the controller is destroyed, by removing the controller's element from the page or calling destroy on the controller, these events are unbound, preventing memory leaks.
The following are examples of actions with descriptions of what the listen for:
"li click"
- clicks on or withinli
elements within the controller element."mousemove"
- mousemoves within the controller element."{window} click"
- clicks on or within the window.
Action functions get called back with the jQuery-wrapped element or object that the event happened on and the event. For example:
"li click": function( el, ev ) {
assertEqual(el[0].nodeName, "li" )
assertEqual(ev.type, "click")
}
$.Controller supports templated actions. Templated actions can be used to bind to other objects, customize the event type, or customize the selector.
Controller replaces the parts of your actions that look like {OPTION}
with a value in the controller's options or the window.
The following is a skeleton of a menu that lets you customize the menu to show sub-menus on different events:
$.Controller("Menu",{
"li {openEvent}" : function(){
// show subchildren
}
});
//create a menu that shows children on click
$("#clickMenu").menu({openEvent: 'click'});
//create a menu that shows children on mouseenter
$("#hoverMenu").menu({openEvent: 'mouseenter'});
We could enhance the menu further to allow customization of the menu element tag:
$.Controller("Menu",{
defaults : {menuTag : "li"}
},{
"{menuTag} {openEvent}" : function(){
// show subchildren
}
});
$("#divMenu").menu({menuTag : "div"})
Templated actions let you bind to elements or objects outside the controller's element. For example, the Task model from the $.Model section produces a "created" event when a new Task is created. We can make our list widget listen to tasks being created and automatically add these tasks to the list like:
$.Controller("List", {
defaults : {
template: "items.ejs"
}
},{
init : function(){
this.element.html( this.options.template, this.options.model.findAll() );
},
"{Task} created" : function(Task, ev, newTask){
this.element.append(this.options.template, [newTask])
}
})
The "{Task} create"
gets called with the Task model, the created event, and the newly created Task. The function uses the template to render a list of tasks (in this case there is only one) and add the resulting html to the element.
But, it's much better to make List work with any model. Instead of hard coding tasks, we'll make controller take a model as an option:
$.Controller("List", {
defaults : {
template: "items.ejs",
model: null
}
},{
init : function(){
this.element.html( this.options.template, this.options.model.findAll() );
},
"{model} created" : function(Model, ev, newItem){
this.element.append(this.options.template, [newItem])
}
});
// create a list of tasks
$('#tasks').list({model: Task, template: "tasks.ejs"});
Now we will enhance the list to not only add items when they are created, but update them and remove them when they are destroyed. To do this, we start by listening to updated and destroyed:
"{model} updated" : function(Model, ev, updatedItem){
// find and update the LI for updatedItem
},
"{model} destroyed" : function(Model, ev, destroyedItem){
// find and remove the LI for destroyedItem
}
You'll notice here we have a problem. Somehow, we need to find the element that represents particular model instance. To do this, we need to label the element as belonging to the model instance. Fortunately,
To label the element with a model instance within an EJS view, you simply write the model instance to the element. The following might be tasks.ejs
<% for(var i =0 ; i < this.length; i++){ %>
<% var task = this[i]; %>
<li <%= task %> > <%= task.name %> </li>
<% } %>
tasks.ejs
iterates through a list of tasks. For each task, it creates an li
element with the task's name. But, it also adds the task to the element's jQuery data with: <li <%= task %> >
.
To later get that element given a model instance, you can call modelInstance.elements([CONTEXT])
. This returns the jQuery-wrapped elements the represent the model instance.
Putting it together, list becomes:
$.Controller("List", {
defaults : {
template: "items.ejs",
model: null
}
},{
init : function(){
this.element.html( this.options.template, this.options.model.findAll() );
},
"{model} created" : function(Model, ev, newItem){
this.element.append(this.options.template, [newItem])
},
"{model} updated" : function(Model, ev, updatedItem){
updatedItem.elements(this.element)
.replaceWith(this.options.template, [updatedItem])
},
"{model} destroyed" : function(Model, ev, destroyedItem){
destroyedItem.elements(this.element)
.remove()
}
});
// create a list of tasks
$('#tasks').list({model: Task, template: "tasks.ejs"});
It's almost frighteningly easy to create abstract, reusable, memory safe widgets with JavaScriptMVC.
I'm keeping the remainder of these sections for reference, but they aren't part of the controller article.
Controllers provide automatic determinism for your widgets. This means you can look at a controller and know where in the DOM they operate, and vice versa.
First, when a controller is created, it adds its underscored name as a class name on the parent element.
<div id='historytab' class='history_tabs'></div>
You can look through the DOM, see a class name, and go find the corresponding controller.
Second, the controller saves a reference to the parent element in this.element. On the other side, the element saves a reference to the controller instance in jQuery.data.
$("#foo").data('controllers')
A helper method called controller (or controllers) using the jQuery.data reference to quickly look up controller instance on any element.
$("#foo").controller() // returns first controller found
$("#foo").controllers() // returns an array of all controllers on this element
Finally, actions are self labeling, meaning if you look at a method called ".foo click", there is no ambiguity about what is going on in that method.
If you name an event with the pattern "selector action", controllers will set these methods up as event handlers with event delegation. Even better, these event handlers will automatically be removed when the controller is destroyed.
".todo mouseover" : function( el, ev ) {}
The el passed as the first argument is the target of the event, and ev is the jQuery event. Each handler is called with "this" set to the controller instance, which you can use to save state.
Part of the magic of controllers is their automatic removal and cleanup. Controllers bind to the special destroy event, which is triggered whenever an element is removed via jQuery. So if you remove an element that contains a controller with el.remove() or a similar method, the controller will remove itself also. All events bound in the controller will automatically clean themselves up.
Controllers can be given a set of default options. Users creating a controller pass in a set of options, which will overwrite the defaults if provided.
In this example, a default message is provided, but can is overridden in the second example by "hi".
$.Controller("Message", {
defaults : {
message : "Hello World"
}
},{
init : function(){
this.element.text(this.options.message);
}
})
$("#el1").message(); //writes "Hello World"
$("#el12").message({message: "hi"}); //writes "hi"
Controllers provide the ability to set either the selector or action of any event via a customizable option. This makes controllers potentially very flexible. You can create more general purpose event handlers and instantiate them for different situations.
The following listens to li click for the controller on #clickMe, and "div mouseenter" for the controller on #touchMe.
$.Controller("Hello", {
defaults: {item: “li”, helloEvent: “click”}
}, {
“{item} {helloEvent}" : function(el, ev){
alert('hello')� el // li, div
}
})
$("#clickMe").hello({item: “li”, helloEvent : "click"});
$("#touchMe").hello({item: “div”, helloEvent : "mouseenter"});
JavaScriptMVC applications often use OpenAjax event publish and subscribe as a good way to globally notify other application components of some interesting event. The jquery/controller/subscribe method lets you subscribe to (or publish) OpenAjax.hub messages:
$.Controller("Listener",{
"something.updated subscribe" : function(called, data){}
})
// called elsewhere
this.publish("some.event", data);
Controllers provide support for many types of special events. Any event that is added to jQuery.event.special and supports bubbling can be listened for in the same way as a DOM event like click.
$.Controller("MyHistory",{
"history.pagename subscribe" : function(called, data){
//called when hash = #pagename
}
})
Drag, drop, hover, and history and some of the more widely used controller events. These events will be discussed later.