O'Reilly logo

JavaScript Web Applications by Alex MacCaw

Stay ahead with the world's most comprehensive technology and business learning platform.

With Safari, you learn the way you learn best. Get unlimited access to videos, live online training, learning paths, books, tutorials, and more.

Start Free Trial

No credit card required

Adding a Bit of Context

Using a local context is a useful way of structuring modules, especially when it comes to registering callbacks to events. As it stands, the context inside our module is global—this is equal to window:

(function(){
  assertEqual( this, window );
})();

If we want to scope the context, we need to start adding functions onto an object. For example:

(function(){
  var mod = {};

  mod.contextFunction = function(){
    assertEqual( this, mod );
  };

  mod.contextFunction();
})();

The context inside contextFunction() is now local to our mod object. We can start using this without worrying about creating global variables. To give you a better indication of how it would be used in practice, let’s further flesh out that example:

(function($){

  var mod = {};

  mod.load = function(func){
    $($.proxy(func, this));
  };

  mod.load(function(){
    this.view = $("#view");
  });

  mod.assetsClick = function(e){
    // Process click
  };

  mod.load(function(){
    this.view.find(".assets").click(
      $.proxy(this.assetsClick, this)
    );
  });

})(jQuery);

We’re creating a load() function that takes a callback, executing it when the page has loaded. Notice that we’re using jQuery.proxy() to ensure that the callback is invoked in the correct context.

Then, when the page loads, we’re adding a click handler onto an element, giving it a local function, assetsClick(), as a callback. Creating a controller doesn’t need to be any more complicated than that. What’s important is that all of the controller’s state is kept local and encapsulated cleanly into a module.

Abstracting into a Library

Let’s abstract that library out so we can reuse it with other modules and controllers. We’ll include the existing load() function and add new ones like proxy() and include():

(function($, exports){
  var mod = function(includes){
    if (includes) this.include(includes);
  };
  mod.fn = mod.prototype;

  mod.fn.proxy = function(func){
    return $.proxy(func, this);
  };

  mod.fn.load = function(func){
    $(this.proxy(func));
  };

  mod.fn.include = function(ob){
    $.extend(this, ob);
  };

  exports.Controller = mod;
})(jQuery, window);

proxy() ensures that functions are executed in the local context, which is a useful pattern for event callbacks. The include() function is just a shortcut for adding properties onto the controller, saving some typing.

We’re adding our library to the exports object, exposing it as the global Controller variable. Inside the module we can instantiate a Controller object using its constructor function. Let’s go through a simple example that toggles an element’s class depending on whether the mouse is over the element:

(function($, Controller){

  var mod = new Controller;

  mod.toggleClass = function(e){ 
    this.view.toggleClass("over", e.data);
  };

  mod.load(function(){
    this.view = $("#view");
    this.view.mouseover(this.proxy(this.toggleClass), true);
    this.view.mouseout(this.proxy(this.toggleClass), false);
  });

})(jQuery, Controller);

When the page loads, we’re creating a view variable and attaching some event listeners. They in turn call toggleClass() when the mouse moves over the element, toggling the element’s class. You can see the full example in this book’s accompanying files, in assets/ch04/modules.html.

Granted, using context rather than local variables means there is probably more code to write, what with all the usage of this. However, the technique gives us much greater scope for reusing code and including mixins. For example, we could add a function onto every Controller instance by setting a property on its prototype:

Controller.fn.unload = function(func){
  jQuery(window).bind("unload", this.proxy(func));
};

Or, we could extend an individual controller by using the include() function we defined earlier, passing it an object:

var mod = new Controller;
mod.include(StateMachine);

The StateMachine object, in this example, could be reused over and over again with our other modules, preventing us from duplicating code and keeping things DRY (don’t repeat yourself).

Loading Controllers After the Document

As it stands, some parts of our controllers are being loaded before the DOM, and other parts are in callbacks to be invoked after the page’s document has loaded. This can be confusing because the controller’s logic is being executed under different states, resulting in a lot of document load callbacks.

We can solve this in one fell swoop by loading controllers after the DOM. I personally advocate this approach because it ensures that you don’t need to think constantly about what state the page’s DOM is in when accessing elements.

Let’s first take advantage and clear up our library, making our controllers a bit cleaner. The Controller class doesn’t need to be a constructor function because the context switch needed when generating subcontrollers is unnecessary here:

// Use global context, rather than the window
// object, to create global variables
var exports = this;

(function($){
  var mod = {};

  mod.create = function(includes){
    var result = function(){
      this.init.apply(this, arguments);
    };

    result.fn = result.prototype;
    result.fn.init = function(){};

    result.proxy    = function(func){ return $.proxy(func, this); };
    result.fn.proxy = result.proxy;

    result.include = function(ob){ $.extend(this.fn, ob); }; 
    result.extend  = function(ob){ $.extend(this, ob); };
    if (includes) result.include(includes)

    return result;
  };

  exports.Controller = mod;
})(jQuery);

Now we can use our new Controller.create() function to create controllers, passing in an object literal of instance properties. Notice that the entire controller is wrapped in jQuery(function(){ /* ... */ }). This is an alias for jQuery.ready(), and it ensures that the controller is loaded only after the page’s DOM has fully initialized:

jQuery(function($){
  var ToggleView = Controller.create({
    init: function(view){
      this.view = $(view);
      this.view.mouseover(this.proxy(this.toggleClass), true);
      this.view.mouseout(this.proxy(this.toggleClass), false);          
    },

    this.toggleClass: function(e){
      this.view.toggleClass("over", e.data);          
    }
  });

  // Instantiate controller, calling init()
  new ToggleView("#view");
});

The other significant change we’ve made is passing in the view element to the controller upon instantiation, rather than hardcoding it inside. This is an important refinement because it means we can start reusing controllers with different elements, keeping code repetition to a minimum.

Accessing Views

A common pattern is to have one controller per view. That view has an ID, so it can be passed to controllers easily. Elements inside the view then use classes, rather than IDs, so they don’t conflict with elements in other views. This pattern provides a good structure for a general practice, but it should not be conformed to rigidly.

So far in this chapter we’ve been accessing views by using the jQuery() selector, storing a local reference to the view inside the controller. Subsequent searches for elements inside the view are then scoped by that view reference, speeding up their lookup:

// ...  
init: function(view){
  this.view = $(view);
  this.form = this.view.find("form");
}

However, it does mean that controllers fill up with a lot of selectors, requiring us to query the DOM constantly. We can clean this up somewhat by having one place in the controller where selectors are mapped to variables names, like so:

elements: {
  "form.searchForm": "searchForm",
  "form input[type=text]": "searchInput"
}

This ensures that the variables this.searchForm and this.searchInput will be created on the controller when it’s instantiated, set to their respective elements. These are normal jQuery objects, so we can manipulate them as usual, setting event handlers and fetching attributes.

Let’s implement support for that elements mapping inside our controllers, iterating over all the selectors and setting local variables. We’ll do this inside our init() function, which is called when our controller is instantiated:

var exports = this;

jQuery(function($){
  exports.SearchView = Controller.create({
    // Map of selectors to local variable names
    elements: {
      "input[type=search]": "searchInput",
      "form": "searchForm"
    },

    // Called upon instantiation
    init: function(element){
      this.el = $(element);
      this.refreshElements();
      this.searchForm.submit(this.proxy(this.search));
    },

    search: function(){
      console.log("Searching:", this.searchInput.val());
    },

    // Private

    $: function(selector){
      // An `el` property is required, and scopes the query
      return $(selector, this.el);
    },

    // Set up the local variables
    refreshElements: function(){
      for (var key in this.elements) {
        this[this.elements[key]] = this.$(key);
      }
    }
  });

  new SearchView("#users");
});

refreshElements() expects every controller to have a current element property, el, which will scope any selectors. Once refreshElements() is called, the this.searchForm and this.searchInput properties will be set on the controller and are subsequently available for event binding and DOM manipulation.

You can see a full example of this in this book’s accompanying files, in assets/ch04/views.html.

Delegating Events

We can also take a stab at cleaning up all that event binding and proxying by having an events object that maps event types and selectors to callbacks. This is going to be very similar to the elements object, but instead will take the following form:

events: {
  "submit form": "submit"
}

Let’s go ahead and add that to our SearchView controller. Like refreshElements(), we’ll have a delegateEvents() function that will be called when the controller is instantiated. This will parse the controller’s events object, attaching event callbacks. In our SearchView example, we want the search() function to be invoked whenever the view’s <form /> is submitted:

var exports = this;

jQuery(function($){
  exports.SearchView = Controller.create({
    // Map all the event names, 
    // selectors, and callbacks
    events: {
      "submit form": "search"
    },

    init: function(){
      // ...
      this.delegateEvents();
    },

    search: function(e){ /* ... */ },

    // Private

    // Split on the first space
    eventSplitter: /^(\w+)\s*(.*)$/,

    delegateEvents: function(){
      for (var key in this.events) {
        var methodName = this.events[key];
        var method     = this.proxy(this[methodName]);

        var match      = key.match(this.eventSplitter);
        var eventName  = match[1], selector = match[2];

        if (selector === '') {
          this.el.bind(eventName, method);
        } else {
          this.el.delegate(selector, eventName, method);
        }
      }
    }
  });

Notice we’re using the delegate() function inside delegateEvents(), as well as the bind() function. If the event selector isn’t provided, the event will be placed straight on el. Otherwise, the event will be delegated, and it will be triggered if the event type is fired on a child matching the selector. The advantage of delegation is that it often reduces the amount of event listeners required—i.e., listeners don’t have to be placed on every element selected because events are caught dynamically when they bubble up.

We can push all those controller enhancements upstream to our Controller library so they can be reused in every controller. Here’s the finished example; you can find the full controller library in assets/ch04/finished_controller.html:

  var exports = this;

  jQuery(function($){
    exports.SearchView = Controller.create({
      elements: {
        "input[type=search]": "searchInput",
        "form": "searchForm"
      },

      events: {
        "submit form": "search"
      },

      init: function(){ /* ... */ },

      search: function(){
        alert("Searching: " + this.searchInput.val());
        return false;
      },
    });

    new SearchView({el: "#users"});
  });

With Safari, you learn the way you learn best. Get unlimited access to videos, live online training, learning paths, books, interactive tutorials, and more.

Start Free Trial

No credit card required