Extending events and properties of Backbone views

posted on February 7, 2015

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
    },
    ...
});