JavaScript data binding: auto-eventing

Hi Habr! In this article, I will consider one of the options for building a client-server web application architecture in terms of data binding. This option does not pretend to be original, but personally I have allowed to significantly reduce development time, as well as optimize the download time.


Problem


Let's say we have a large web interface that should display and allow the user to interact with several hundred elements of various types at the same time.
For each type of object, we have our own class, and we naturally want to associate user actions in the interface with the methods of these classes.
In this case, we will consider a simple example - an object has a name (name property), and the object management interface is a text field where this name is entered. When changing the field, we want the property of the object to change, and the new value is sent to the server (SetName method).

Typically, a typical application initialization sequence looks like this:
  1. Initialize all objects
  2. Build a DOM tree of the interface
  3. Get links to key interface elements (object container, object editing form, etc.)
  4. Initialize the interface with the current values ​​of the object properties
  5. Assign object methods as event handlers to interface elements


Head-on


The simplest implementation for a single object is as follows:
function InitDomForObject(object){
        //строим DOM, запоминаем ссылки, заполняем начальными значениями
        object.container = $("
", {className : "container"}); object.inputs.name = $("", {value : object.data.name}).appendTo(object.container); ... //назначаем обработчики object.inputs.name.change($.proxy(object, "SetName")); ... }


The obvious disadvantages of this implementation are:

  1. Strictly related layout and JS code
  2. A huge amount of code for building the DOM and assigning event handlers (for complex interfaces)
  3. With this approach, building the DOM will take too much time on the browser, because we will call createElement, setAttribute and appendChild several times in a loop for each object, and these are quite "heavy" operations


Patterns


Faced with such difficulties, it immediately occurred to us to use templates and not generate the DOM manually, because we are well aware that if we build an interface as a string with HTML code, it will be processed by the browser almost instantly.

We built HTML (for example, on the server side), and output it to the browser, but faced with the fact that we had to look for elements in a large DOM tree.
Due to the fact that we need to assign handlers to elements - we cannot use lazy initialization, we still have to find absolutely all the interface elements when loading the application.
Let's say all our objects of different types have end-to-end numbering, and are collected in an array of Objects.
Now we have two ways:

Option A

Search for elements by class by writing the object ID in the rel attribute.
The HTML representation of a single object will look like this:


Then, for each type of interface element, we assign something like this handler:
$(".objectName").change(function(){
       var id = $(this).attr("rel"); //узнаем, к какому объекту принадлежит
       Objects[id].SetName(); //вызываем функцию
});


And if for some reason we also want to save links to each interface element individually, we generally have to write a spooky cycle across the entire array of objects: this is long and inconvenient.

Option B

Of course, we know that finding elements by id is much faster!
Since our id must be unique, we can use for example the following format “name_12345” (“role_identifier”):


The assignment of handlers will look almost the same:
$(".objectName").change(function(){
      var id = this.id.split("_")[1];//узнаем, к какому объекту принадлежит
      Objects[id].SetName(); //вызываем функцию
});


Since now all elements can be found by ID, and handlers are already assigned, we may well not collect all the links at once, but do it as necessary ("lazy"), implementing the GetElement method somewhere in the base prototype of all our objects:
function GetElement(element_name){
      if(!this.element_cache[element_name])
                this.element_cache[element_name] = document.getElementById(element_name + '_' + this.id);
      return this.element_cache[element_name];
}


Searching by ID is already very fast, but the cache has never bothered anyone. However, if you intend to remove items from the tree, keep in mind that as long as there are links to them, the garbage collector will not get to them.

We will only have one problem: a large amount of destination code for event handlers, because for each type of interface element we will have to assign a separate handler for each event! Total number of appointments will be equal = number of objects X number of elements X number of events .

Final decision


Recall the remarkable property of events in the DOM: capture and bubbling :
after all, we can assign event handlers to the root element, because all the same, all events pass through it!

We could use the jQuery.live method for this purpose, and would come to the same thing that is written above: Option B , namely, a large number of destination code for the handlers.

Instead, we will write a small “router” for our events. We agree to start all id elements with a special character to exclude elements for which no event handlers are needed. The router will try to redirect the event to the object to which this element “belongs” and call the appropriate method on it.
var Router={
   EventTypes : ['click', 'change', 'dblclick', 'mouseover', 'mouseout', 'dragover', 'keypress', 'keyup', 'focusout', 'focusin'], //перечисляем нужные события
    Init : function(){
        $(document.body).bind(Router.EventTypes.join(" "), Router.EventHandler);
                        //Назначаем обработчики
    },
    EventHandler : function(event){ //единый обработчик
        if(event.target.id.charAt(0) != '-')
            return;
        var route = event.target.id.substr(1).split('_');
        var elementRole = route[0];
        var objectId = route[1];
        var object = App.Objects[objectId]; //находим объект
        if(object == null) return;
        var method = object[elementRole + '_' + event.type];
        if  (typeof method == 'function') //пытаемся найти нужный метод
       {
        event.stopPropagation(); //никаких других обработчиков у нас нет
        return method.call(object, event); // вызываем метод в контексте объекта, и передаем ему в качестве параметра объект события
       }
    }
}


Usage example:


SomeObject.prototype = {
        …
        Name_blur : function(e){ //вызывается при событии blur
                this.data.name = e.target.value;
                this.GetElement("container").title=this.data.name;//меняем title у контейнера, быстрый поиск по id пригодился
                this.SaveToServer("name");
        }
}


Advantages of the solution:

  • No need to assign many separate handlers, minimum code
  • Each event is automatically routed to the desired method of the object, and the method is called in the right context.
  • You can add / remove interface elements from the layout at any time, you only need to implement the appropriate methods for your objects
  • The number of handler assignments is equal to the number of event types (and not the number of objects X the number of elements X the number of events )

Minuses:

  • It is necessary in a special way and in large quantities to assign ID elements. (This only requires changing templates)
  • With every event in each element, our EventHandler is called (this almost does not reduce performance, since we immediately discard unnecessary elements, and also call stopPropagation)
  • The numbering of objects should be end-to-end (you can split the Objects array into several - one for each of the types of objects, or even enter separate internal indexes instead of using the same IDs as on the server)

Other options



Fast decision

If our interface were standard and simple (i.e., using only standard controls), we would apply the usual data binding technique, for example jQuery DataLink:
$("#container").link(object,
        {
                name : "objectName"
        }
);

When changing the property of an object, the value of the text field changes and vice versa.
However, in reality, we often use non-standard interface elements, and more complex dependencies than “One interface element to one property of an object”. Changing one field can affect several properties at once, and for different objects.

For example, if we have a user ( UserA ) with some rights that belongs to a group, and also an element in which you can select a group ( GroupA or GroupB ).
Then changing the selection in this list will entail many other changes:
In the data:
  • UserA.group property will change
  • UserA object will be deleted from GroupA.users array
  • UserB object will be deleted from GroupB.users array
  • The array UserA.permissions will change

In the interface:
  • The list of user rights will change
  • Counters showing the number of users in the group will change.

Etc.
Such complex dependencies cannot be easily resolved. Just in this case, the method described above will do.

Similar solution

A similar approach was applied in VKontakte: events are assigned to each element through the corresponding attributes (onclick, onmouseover, etc.). Only the template is built on the client side, not the server.
However, event processing is not delegated to any object:

Instead, methods of global objects are called, which is not very good if, for example, the OOP approach is used in the application.

We could change this principle, nevertheless directing events to the necessary methods, but it would not look very beautiful:


Instead, we can adapt our router function to this approach:

function Router(event){
        var route = event.target.id.split('_');
        var elementRole = route[0];
        var objectId = route[1];
        var object = App.Objects[objectId]; //находим объект
        if(object == null) return;
        var method = object[elementRole + '_' + event.type];
        if  (typeof method == 'function') //пытаемся найти нужный метод
       {
          event.stopPropagation(); //никаких других обработчиков у нас нет
          return method.call(object, event); // вызываем метод в контексте объекта, и передаем ему в качестве параметра объект события
       }
}

This will save us from processing a large number of events for each "sneeze" user, but make us describe the events he needs in the template of each element, and even with inline code.

Which of these evils is the smallest, and accordingly which method to choose, depends on the context and the specific task.

Also popular now: