- Views...The what and the why?
- Creating Views
- IC.App.View.Base
- IC.App.View.Behaviour
- IC.App.View.Collection
- View Communication
The secret to building large apps is never build large apps. Break your applications into small pieces. Then, assemble those testable, bite-sized pieces into your big application Justin Meyer, author JavaScriptMVC
To promote modularity and reuseability within our code base you should be building your code to follow the following principles:
- Don't Repeat Yourself - Similar functionality shouldn't be copied/duplicated in different modules. Abstract it out to a common module.
- Single Responsibility Principle - Modules should do one thing and one thing well.
- Highly Cohesive - Should all code in the module be there? Does it contribute to the singular purpose of the module? If some code seems "smelly" and doesn't seem to fit within the goal of your module, maybe it could be abstracted out to another module.
- Loose Coupling - Modules shouldn't necessarily know about the existence of other modules. If you change the implementation of module A, will it have a knock-on effect and cause the refactoring of Module B. While there will always be a degree of coupling in any system, try to keep it to a minimum.
Views allow us to follow these principles by encapsulating tightly-focused bits of UI into single modules of code. They are based around Backbone Views with some variations. They differ from Backbone views in that we do not have models or templates. We instead are enhancing existing rendered HTML on the page and using views to create our code in a structured hierarchical manner. This helps us build our interfaces from the bottom-up rather than top-down.
Creating our code in this manner makes it more unit-testable, maintainable and reuseable throughout the site.
Currently we have three different types of views. Base
views, Behaviour
views and Collection
views. Base
views are single chunks of UI whereas Collection
are for creating views which contain lists of repeatable Base
views. Both follow the same format for creation.
//Define the View & namespace
IC.define('App.View.ConversationInbox.Card', function(IC, $, _) {
//Return the view object
return IC.App.View.Base.extend({
... view functionality ...
});
});
//Instantiate/Use the view elsewhere
var Card = new IC.App.View.ConversationInbox.Card({
el: this.$('.view_ConversationInbox_Card')
... instance view config ...
});
When creating a view append we employ of the convention adding a .view_ViewNamespace
HTML class to the root element of the view. This allows you to, when looking at the HTML on the page, easily deduce what type of view it is and what file View file it refers back to. Taking the above example, the view_ConversationInbox_Card
class tells us that this is a Card view, that the view is at the namespace IC.App.View.ConversationInbox.Card
and that the file for it is at javascripts/views/conversation-inbox/card.js
.
Native root DOM element
el
wrapped in a jQuery
object
Function to set up the view, called when the view is instantiated (using new
). Use this function to set custom view properties, initialize child views or merge passed in instance config. Properties set on this
will be available throughout the module.
//Define the view
IC.define('App.View.Test', function(IC, $, _){
...
initialize: function(options) {
// Set default property
this.setting = 'foo';
},
_internal_function: function(){
console.log(this.setting); // 'foo'
}
...
});
//Create instance of the view
new IC.App.View.Test({
el: '.view_SomeView',
setting: 'bar'
});
Any options which passed in the options
can be added to the this
object for use within the whole view. Mixing in the options
also allows you to overwrite default configuration settings.
//Define the view
IC.define('App.View.Test', function(IC, $, _){
...
initialize: function(options) {
// Set default property
this.setting = 'foo';
console.log(this.setting); // 'foo'
//Mixin instance config
_.extend(this, options);
console.log(this.setting); // 'bar'
}
...
});
//Create instance of the view
new IC.App.View.Test({
el: '.view_SomeView',
setting: 'bar'
});
Convenience configuration object for setting up view events.
Setting an event handler on the view's root element, $el
.
'event_type': 'event_handler'
Setting a delegated event handler on child elements of the view.
'event_type selector': 'event_handler'
You callbacks will always run in the context of the view object.
events: {
'click': '_on_click', //root element
'click .js_clicked_child': '_on_delegated_click' //child element
},
_on_click: function(){
console.log(this); //"this" refers to the view
},
_on_delegated_click: function(){
console.log(this); //"this" refers to the view
}
Used to insert child views and attach behaviours to the current view. This is important to do because if destroy()
is called on the current view, it will first clean up and destroy all child views before destroying itself.
var test_view = this.register_view(new IC.App.View.Test({el: '.view_SomeView'}));
console.log(test_view) //newly instantiated view object
Automatically unbinds all events created in the events
config, calls the custom created on_destroy()
function and removes the view from the DOM.
var test_view = this.register_view(new IC.App.View.Test({el: '.view_SomeView'}));
test_view.destroy();
If any custom clean up of the View needs to be done add it to the on_destroy
function. This will fire automatically when the destroy()
function is called.
destroy()
also cleans up all child views, destroying the bottom of the chain first and recursively working its way back up to the intial view that made the destroy()
call.
For example, take the following view hierarchy:
Parent View
- Child View 1
- Child View 2
- Child View 3
- Child View 4
- Child View 5
Calling destroy()
on the Parent View will result in the following destroy order.
Child View 1
Child View 4
Child View 3
Child View 5
Child View 2
Parent View
//Set up View namespace
IC.define('App.View.ConversationInbox.Card', function(IC, $, _) {
//Return the view object
return IC.App.View.Base.extend({
//Constructor to init the view instance
initialize: function (options) {
_.extend(this, options);
},
//Convenience method for setting up events for the view
events: {
'click': '_on_card_click'
},
//Custom unbinding of events and cleanup of the view. Fires during the `destroy()` call.
on_destroy: function(){
},
//Custom functions
_on_card_click: function(){
}
});
});
var Card = new IC.App.View.ConversationInbox.Card({
el: this.$('.view_ConversationInbox_Card')
});
Behaviour
views are pre-existing modules of re-useable functionality which can be added to your view to enhance it. Examples of Behaviour
views include:
IC.App.View.Behaviour.Selector
- functionality to abstract the selection logic from the user list and the conversation inbox into a common view.IC.App.View.Behaviour.Paging
- functionality to implement paging so if you added this to your list you would immediately have infinite scroll functionality.
Behaviour
views extend the Base
view so every property available to the Base
view is also available to a Behaviour
view.
Similar to regular views, Behaviour
views have their own HTML class name convention. Class names which follow the convention .behaviour_Name
should be added to the root element of the view. For example, the Selector
behaviour would have the class name .behaviour_Selector
.
You can find all the currently implemented behaviours here
Collection
views are used to generate a list of specified views. They are very similar to Base
views as they have the exact same API/properties with some additional features on top. Views to be created will
The exact same API/Properties as the Base view with the following additions. Base
view elements should be direct children of the Collection
root element.
A string value denoting what the type of the child view to be created.
item_view: 'IC.App.View.SomeView`
Pass custom configuration properties to the item_view
when they are initialised.
Doing the following
...
item_view: 'IC.App.View.SomeView'
item_view_options: {
test_config: true
}
...
works the same way as if we were to initialise a regular view.
new IC.App.View.SomeView({
test_config: true
});
When adding new items to the list, add them to the Collection
using the append
function. You can pass in either a html string or a jQuery collection. When appended each list item is intialised with the item_view
View. append
adds new list items to the end of the list.
/*
<ul class="view_Someview">
<li>content</li>
<li>content</li>
<li>content</li>
</ul>
*/
//Init the Collection View
var test_view = new IC.App.View.Collection.SomeView({
el: '.view_SomeView',
item_view: 'IC.App.View.TestItem',
item_view_options: {
custom_setting: true
}
});
//Listen to an event which tells us that some new content has loaded
event_bus.on('loaded.content', function(html){
/*
<li>content</li>
<li>content</li>
<li>content</li>
*/
//Add the new html to the end of the current collection
test_view.append(html);
});
Works exactly the same as append
except it adds the new items to the start of the list before any existing list items.
To promote loose coupling within our apps we use an eventing system based around the Observer Pattern (also sometimes referred to as Publish/Subscribe or PubSub). Events are used to signify that something has occurred.
The IC.Util.EventAggregator()
object is the base Events object in the system. To create an event object we simply create an instance of that.
var object = new IC.Util.EventPropagation();
Subscribe to events on an object using the on
function.
// Subscribe to the 'replied.thread.conversation_inbox' event
object.on('replied.thread.conversation_inbox', function(){
console.log('This thread has been replied to!');
});
//Trigger the 'replied.thread.conversation_inbox' event
object.trigger('replied.thread.conversation_inbox');
//=> Logs 'This thread has been replied to!'
When creating namespaces, as a convention, create a period-delimited string working from right to left in order of hierarchy, with the right-most being the more general namespace and the left being the event of the specific module.
For example, replied.thread.conversation_inbox
tells us that a replied
event has occurred in a thread
in the conversation_inbox
section of the interface.
Unsubscribes from an object event.
// Removes just the `on_change` callback.
object.off("change", on_change);
// Removes all "change" callbacks.
object.off("change");
// Removes the `onChange` callback for all events.
object.off(null, on_change);
// Removes all callbacks for `context` for all events.
object.off(null, null, context);
// Removes all callbacks on `object`.
object.off();
Triggers an event on a given object. You may also pass in custom parameters to the event.
// Subscribe to the 'replied.thread.conversation_inbox' event
object.on('replied.thread.conversation_inbox', function(data){
console.log('Hello! ' + data.name);
});
//Trigger the 'replied.thread.conversation_inbox' event
object.trigger('replied.thread.conversation_inbox', [{
name: 'Pat'
}]);
//=> Logs 'Hello Pat'
If passing custom parameters, as a convention, pass an object literal with specific property names rather than a list of params. This reduces the risk of error due to parameters being out of order or being null
/undefined
.
//Bad
object.trigger('replied.thread.conversation_inbox', ['a', 'b', 'c', 'd']);
//Good
object.trigger('replied.thread.conversation_inbox', [{
a: 'a',
b: 'b',
c: 'c',
d: 'd'
}]);
There are two types of contexts in which the events can operate. At an app-wide level and at the View level.
By default, app-wide events can be subscribed, unsubscribed or triggered on the IC.App.bus
object. These are completely decoupled and any module on the page can listen to any event on the app-wide bus.
IC.App.bus.on('added.segment', function(data){
console.log('A new segment, ' + data.segment_name + ', has been added');
});
IC.App.bus.trigger('added.segment', [{
segment_name: 'Test Segment',
}]);
// => 'A new segment, Test Segment, has been added'
If you need parent-child view interaction you can create an event bus at the parent level and pass the event bus instance to the child view. In the case of deeply-nested views, you can pass the parent's event bus down the view chain as far you need it go.
For example:
//Top-most "Level 1" view
IC.define('App.View.Level1', function(IC, $, _) {
//Return the view object
return IC.App.View.Base.extend({
initialize: function(options){
// Copy "options" properties to "this"
_.extend(this,options);
//Set up the event bus
this.level1_bus = new IC.Util.EventAggregator();
//Set up the callback to handle the 'something' event
this.level1_bus.on('something', function(data){
console.log('Something happened on Level' + data.level);
});
//Initialise and pass through the event bus object to the child view
var level2_view = new IC.App.View.Level2({
el: this.$('.view_Level2'),
level1_bus: this.level1_bus
});
},
});
});
//Mid-level "Level 2" View
IC.define('App.View.Level2', function(IC, $, _) {
//Return the view object
return IC.App.View.Base.extend({
initialize: function(options){
// Copy "options" properties to "this"
_.extend(this,options);
//Initialise and pass through the event bus object to the child view
var level3_view = new IC.App.View.Level3({
el: this.$('.view_Level3'),
level1_bus: this.level1_bus
});
//Trigger the 'something' event on Level 2
this.level1_bus.trigger('something', [{
level: '2'
}]);
// => 'Something happened on Level 2'
}
});
});
//Bottom-level "Level 3" View
IC.define('App.View.Level3', function(IC, $, _) {
//Return the view object
return IC.App.View.Base.extend({
initialize: function(config){
// Copy "options" properties to "this"
_.extend(this,options);
//Trigger the 'something' event on Level 2
this.level1_bus.trigger('something', [{
level: '3'
}]);
// => 'Something happened on Level 3'
}
});
});