Extending events and properties of Backbone views
I like Backbone Views because they introduce a conventional way to wrap HTML elements and their presentation logic in a single JavaScript class. Thus, allowing better organization and reuse of my application views. In addition to that, by utilizing JavaScript prototypical inheritance, JavaScript classes can be further extended to allow even better code reuse. But sometimes, when I extend Backbone.View
classes, I want to "extend" super view's functionality instead of overriding it.
When extending Backbone views using the Backbone.View.extend method, the properties passed to the extend
method will override properties defined in the super view. For example, if the super view has an events
hash, any events
passed to the extend
method will override super view's events. Sometimes this might be a desirable result. However, usually when we want to "extend" something we want to add extra functionality rather redefining it existing one.
For example, let's create a modal view. This view will be responsible for showing a modal box with an optional "close" button in its top-right corner and a semi-transparent overlay covering the underlying web page. The overlay can be configured to close the model if it's clicked. The following code presents the possible implementation of the show
method, the events
, and the defaultOptions
attribute in our ModalView
class:
var ModalView = Backbone.View.extend({
events: {
"click .modalViewCurtain": "onCurtainClick",
"click .modalViewCloseButton": "onCloseButtonClick"
},
defaultOptions: {
"showCloseButton": true,
"closeWhenCurtainClicked": true,
"animation": "bubble",
"width": 300,
"height": 140
},
show: function(title, content, options) {
this.options = _.defaults({}, options || {}, this.defaultOptions);
// Rest of the code that shows the modal
},
template: _.template( /* ... */ ),
initialize: function() { /* ... */ },
render: function() { /* ... */ },
hide: function() { /* ... */ },
onCurtainClick: function() {/* ... */ },
onCloseButtonClick: function() { /* ... */ }
});
$("#modalViewDemo").on("click", function() {
(new ModalView()).show("This is ModalView", "Click on the overlay or the close button to close.");
});
Now, assume that we want to extend our ModalView
to something more specific, for example, a DialogView
. In addition to ModelView
's behavior, we want our DialogView
to have two additional buttons at the bottom - "Ok" and "Cancel". By default, the DialogView
should not be closed when the overlay is clicked. Ideally, we would want to subclass the ModalView
and specify only the new events introduced in this subclass and only the new and overridden default options. Like this:
var DialogView = ModalView.extend({
events: {
"click .dialogViewPositiveButton": "onPositiveButtonClick",
"click .dialogViewNegativeButton": "onNegativeButtonClick"
},
defaultOptions: {
"closeWhenCurtainClicked": false, // overridden property
"positiveButtonLabel": "Ok", // new property
"negativeButtonLabel": "Cancel" // new property
},
render: function() {
ModalView.prototype.render.call(this);
// Append "Ok" and "Cancel" buttons
}
});
Due to the JavaScript nature, the events
and defaultOptions
objects defined in the ModalView
class will be completely overridden by the objects defined in the DialogView
class, thus breaking the logic of its superclass. Generally, we can solve this problem by repeating all the events and default options inherited from the ModalView
in the DialogView
. But we won't do that because it is not a scalable or maintainable solution.
Backbone’s __super__
and ECMAScript's hasOwnProperty
to the rescue!
Luckily, Backbone has a __super__
property defined on constructors of its subclasses, including the View
class. We can use this property to traverse the prototype chain of our views while augmenting events
and defaultOptions
objects defined in the inherited views. To decide which properties are "owned" by a specific prototype and not inherited from super-classes, we can use ECMAScript’s hasOwnProperty
method.
For example, we can override the constructor of our ModalView
to achieve our goal:
var ModalView = Backbone.View.extend({
constructor: function() {
var prototype = this.constructor.prototype;
this.events = {};
this.defaultOptions = {};
while (prototype) {
if (prototype.hasOwnProperty("events")) {
_.defaults(this.events, prototype.events);
}
if (prototype.hasOwnProperty("defaultOptions")) {
_.defaults(this.defaultOptions, prototype.defaultOptions);
}
prototype = prototype.constructor.__super__;
}
Backbone.View.apply(this, arguments);
},
...
});
$("#dialogViewDemo").on("click", function() {
(new DialogView()).show("This is DialogView", 'Click on "Ok", "Cancel" or close button to close. Clicking on the overlay will not close the DialogView by default');
});
Now, any view that extends the ModalView
will also extend its events
and defaultOptions
objects with its own.
What about string properties like className
?
Backbone Views also have className
property which defines the class added to the view's root element. By tweaking our constructor, we can also aggregate the class names of the inherited views. And because className
is a string and not an object, extending className
is done by concatenating class names from the prototype chain.
var ModalView = Backbone.View.extend({
constructor: function() {
var prototype = this.constructor.prototype;
this.events = {};
this.defaultOptions = {};
this.className = "";
while (prototype) {
if (prototype.hasOwnProperty("events")) {
_.defaults(this.events, prototype.events);
}
if (prototype.hasOwnProperty("defaultOptions")) {
_.defaults(this.defaultOptions, prototype.defaultOptions);
}
if (prototype.hasOwnProperty("className")) {
this.className += " " + prototype.className;
}
prototype = prototype.constructor.__super__;
}
Backbone.View.apply(this, arguments);
},
...
});
More generic approach
Let's make it more generic by defining an extendProperties
helper method that receives an object with property names as keys and methods that extend these properties as values. This method will traverse the prototype chain and extend the properties using the appropriate extension method. In addition, this method ensures that if a property wasn't defined in any of the inherited classes, including the inheriting class, then its value will remain undefined
:
var ModalView = Backbone.View.extend({
extendProperties: function(properties) {
var prototype = this.constructor.prototype,
propertyName, prototypeValue, extendMethod;
while (prototype) {
for (propertyName in properties) {
if (prototype.hasOwnProperty(propertyName)) {
prototypeValue = prototype[propertyName];
extendMethod = properties[propertyName];
if (!this.hasOwnProperty(propertyName)) {
this[propertyName] = prototypeValue;
} else if (_.isFunction(extendMethod)) {
extendMethod.call(this, propertyName, prototypeValue);
} else if (extendMethod === "defaults") {
_.defaults(this[propertyName], prototypeValue);
}
}
}
prototype = prototype.constructor.__super__;
}
},
constructor: function() {
this.extendProperties({
"events": "defaults",
"defaultOptions": "defaults",
"className": function(propertyName, prototypeValue) {
this[propertyName] += " " + prototypeValue;
}
});
Backbone.View.apply(this, arguments);
},
...
});
What about properties defined as a function?
Backbone allows properties such as className
and events
to be defined as functions to define their values at runtime. To support this feature we can tweak our while loop inside extendProperties
method to use underscore _.result
method like this:
var ModalView = Backbone.View.extend({
extendProperties: function(properties) {
var prototype = this.constructor.prototype,
propertyName, prototypeValue, extendMethod;
while (prototype) {
for (propertyName in properties) {
if (prototype.hasOwnProperty(propertyName)) {
prototypeValue = _.result(prototype, propertyName);
extendMethod = properties[propertyName];
if (!this.hasOwnProperty(propertyName)) {
this[propertyName] = prototypeValue;
} else if (_.isFunction(extendMethod)) {
extendMethod.call(this, propertyName, prototypeValue);
} else if (extendMethod === "defaults") {
_.defaults(this[propertyName], prototypeValue);
}
}
}
prototype = prototype.constructor.__super__;
}
},
...
});
Even more generic approach
What if we would like to add additional extendable properties to our ModalView
? Then we would need to change the ModalView
's constructor every time we want to add a new property. This solution does not align with proper object-oriented design - a superclass shouldn't be aware of properties defined in its subclasses. Thus, to make a more generic approach, we will define the extendableProperties
property on classes whose properties can be extended by subclasses. The value of extendableProperties
will be an object with keys matching the property names and values defining the method to extend them. The extendableProperties
property itself can be extended to list more properties. To make this approach generic, we will create a new base class called BaseVeiw
with extension logic:
var BaseView = Backbone.View.extend({
extendableProperties: {
"events": "defaults",
"className": function(propertyName, prototypeValue) {
this[propertyName] += " " + prototypeValue;
}
},
extendProperties: function(properties) {
var propertyName, prototypeValue, extendMethod,
prototype = this.constructor.prototype;
while (prototype) {
for (propertyName in properties) {
if (properties.hasOwnProperty(propertyName) && prototype.hasOwnProperty(propertyName)) {
prototypeValue = _.result(prototype, propertyName);
extendMethod = properties[propertyName];
if (!this.hasOwnProperty(propertyName)) {
this[propertyName] = prototypeValue;
} else if (_.isFunction(extendMethod)) {
extendMethod.call(this, propertyName, prototypeValue);
} else if (extendMethod === "defaults") {
_.defaults(this[propertyName], prototypeValue);
}
}
}
prototype = prototype.constructor.__super__;
}
},
constructor: function() {
if (this.extendableProperties) {
// First, extend the extendableProperties by collecting all the extendable properties
// defined by classes in the prototype chain.
this.extendProperties({"extendableProperties": "defaults"});
// Now, extend all the properties defined in the final extendableProperties object
this.extendProperties(this.extendableProperties);
}
Backbone.View.apply(this, arguments);
}
});
var ModalView = BaseView.extend({
extendableProperties: {
"defaultOptions": "defaults"
},
className: "modalView",
events: {
"click .modalViewCurtain": "onCurtainClick",
"click .modalViewCloseButton": "onCloseButtonClick"
},
defaultOptions: {
"showCloseButton": true,
"closeWhenCurtainClicked": true,
"animation": "bubble",
"width": 300,
"height": 140
},
...
});
var DialogView = ModalView.extend({
className: "dialogView",
events: {
"click .dialogViewPositiveButton": "onPositiveButtonClick",
"click .dialogViewNegativeButton": "onNegativeButtonClick"
},
defaultOptions: {
"closeWhenCurtainClicked": false, // overridden property
"positiveButtonLabel": "Ok", // new property
"negativeButtonLabel": "Cancel" // new property
},
...
});