Extending events and attributes of the inherited 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 organisation 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 am extending Backbone.View classes, I also want to extend some of their properties instead of just overriding them.

For example, let’s assume that we want to 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. Optionally, clicking on the overlay will close the modal. Following code presents possible implementation of the show method, the events and the defaultOptions objects 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": 100
    },
    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”, and by default the DialogView should not be closed when the overlay is clicked. Ideally, what we would want to do is 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
    }
});

The problem is that due to JavaScript nature, the events and defaultOptions objects defined in 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 of the events and default options inherited from the ModalView in the DialogView. But we won’t do that because it is not a scalable nor a maintainable solution.

Backbone’s __super__ and ECMAScript’s hasOwnProperty to the rescue!

Luckily, Backbone has a __super__ property defined on constructors of any of its inherited classes, including 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 is “owned” by a specific prototype and not inherited from a 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 that will be added to the view’s root element. By tweaking our constructor we can also aggregate 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

Now let’s make it more generic by defining an extendProperties helper method that receives an object with property names as keys and their extend methods as values. This method will traverse the prototype chain and extend required properties using 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 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 properties to our inheritance chain later? Then we will need to change our ModalView constructor every time we add another property. This solution does not stands in line 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 could be extended in their subclasses. The value of this property will be an object defining which properties should be extended. In addition, this property itself may be extended later to define additional extendable properties. And to make all our views to be able to utilize this pattern we will move the constructor and extendProperties method to our new BaseView class:

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

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

comments powered by Disqus