Mobile web/Coding conventions/JavaScript/Views

From MediaWiki.org
Jump to navigation Jump to search

Handling DOM events in JS views[edit]

Delegated declarative dom events[edit]

The preferred way of handling dom events on a view is by using the events map.

In the view properties, we just define a events map (just like we do with defaults and others).

var CounterView = View.extend({
  // ...
  events: {
    'click .increment': 'increment',
    'click .reset': 'reset',
  },
  postRender: function() {
    // View display logic
  },
  increment: function() {
    this.counter++;
  },
  reset: function() {
    this.counter=0;
  },
  // ...
})

The events events map consists of:

{
  'event selector': handler
}

Where:

  • event is any valid jQuery event.
  • selector is an optional valid jQuery selector. If ommited, the event will be bound to the views this.$el.
  • handler is either a function, or a string (name of a method on the view). The handler is defined and will be invoked on the view's this.

Advantages[edit]

  • More efficient. Events are bound with delegate, effectively attaching one DOM listener per event type on the view's root DOM element. (Read http://api.jquery.com/on/#direct-and-delegated-events)
  • Easier to understand. To see which DOM events the view binds and reacts to, just need to find the events map of the view.
  • Event handling code (usually pieces of logic of the view) gets a name, and it's encapsulated on its own method giving us:
    • Reuse of such logic/functionality
    • Easier and better testability
    • Cleaner postRender methods

Good practices and conventions[edit]

Naming handlers[edit]

When creating event handlers, if they are doing DOM stuff, like preventDefault, or extracting data from the DOM node, manipulating the DOM, etc, you should name the handler onEvent, for example onThanksClick or onHeaderToggle.

If possible, extract functionality that makes sense independently into methods with a good name and call them from the handlers as needed.

When creating methods on a view, like the increment example above, that happen to be called when an event happens but make sense independently, you should probably give that method a name increment makes more sense than a handler name onIncrementClick, but that is open to common sense and taste.

For example, if I had to extract what to increment from a DOM element, and then increment it, then I would use both approaches:

// ...
  events: {
    'click .increment': 'onIncrementClick'
  },
  onIncrementClick: function(ev) {
    var amount = parseFloat(this.$('.amount').val());
    this.increment(amount);
  },
  increment: function(amount) {
    this.counter += amount || 1;
  },
// ...

This way we can cleanly test our increment function without touching the DOM, and it can be reused from other parts of the view (or called from external consumers of the view).

Migrating from the manual way[edit]

Getting the DOM element that generated the event[edit]

A common practice you can see with the manual approach is inside the event handler to access the DOM element that initiated the event by doing var $button = $(this). With the events map approach the handlers/methods are at view level and are consistently bound to the view's this, so how do we access the DOM element of the event?

Handler methods get an jQuery.Event as a parameter, so:

onIncrementClick: function(ev) {
  var $incButton = $(ev.target);
  // ...

http://api.jquery.com/category/events/event-object/

Implementing events map on a View in an inheritance chain[edit]

A View that .extends from another View class[edit]

If the View for which you are implementing the events map inherits/extends from another View class, for safety when declaring the events map declare it extending from the parent events map, like this:

var CounterView = Panel.extend({
  // ...
  events: $.extend({}, Panel.prototype.events, {
    'click .increment': 'increment',
    'click .reset': 'reset',
  }),
  // ...
})
Careful with child views[edit]

When implementing events map on a View, be careful with the children classes. If you implement the events map, and a children class implements it also, without extending as explained above, then the child view will be overriding the parent events.

As a quick rule, when implementing it on a view, have a look at sub-views (search for "MyParentView.extends") and if any of the sub-views uses events map, modify it to extend from the parent events map as explained in the example above.

How does it work[edit]

Internally, this is just using jQuery to delegate the events and the selectors to specified handlers bound to the view, no black magic.

This is inspired and brought from Backbone.js, a very simple and clean JS library, similar in philosophy to our own.

Manual approach with jQuery[edit]

To bind events to the view manually, we bind them on the postRender method, like so:

var CounterView = View.extend({
  // ...
  postRender: function() {
    // View display logic
    this.$('.increment').click(function(ev) {
      // ...
    })
    this.$('.reset').click(function(ev) {
      // ...
    })
  },
  // ...
})

After the view has been rendered, we use the this.$ shortcut to select elements within the view's this.$el and bind events as we would do with jQuery. You can use also delegated events with this approach this.$el.on('click', 'td', onCellClick);.

Gotchas[edit]

  • Everytime the view renders, we bind events (multiple of them), this is inefficient, there are better ways.
  • The postRender method on views grows a lot in size, event handling declarations and method code is mixed with display logic.
  • The complexity of the view (arguably) increases, and with it the ability to read and modify the source confidently.