Pull to refresh

Cвязывание данных в JavaScript-приложениях: автороутинг событий

Reading time7 min
Views3.3K
Привет Хабр! В этой статье я рассмотрю один из вариантов построения архитектуры клиент-серверного веб-приложения с точки зрения связывания данных. Этот вариант не претендует на оригинальность, но лично мне позволил значительно сократить время на разработку, а также оптимизировать время загрузки.


Проблема


Допустим у нас есть крупный веб-интерфейс, который должен отображать и позволять пользователю взаимодействовать с несколькими сотнями элементов различных типов одновременно.
Для каждого типа объекта у нас есть свой класс, и мы, естественно, хотим связать действия пользователя в интерфейсе с методами этих классов.
В данном случае мы рассмотрим простой пример – у объекта есть название (свойство name), а интерфейс управления объектом представляет собой текстовое поле, куда это название вводится. При изменении поля мы хотим что бы у объекта менялось свойство, и новое значение отправлялось на сервер (метод SetName).

Обычно типовая последовательность инициализации приложения выглядит так:
  1. Проинициализировать все объекты
  2. Построить DOM-дерево интерфейса
  3. Получить ссылки на ключевые элементы интерфейса (контейнер объекта, форма редактирования объекта и т.д.)
  4. Проинициализировать интерфейс текущими значениями свойств объектов
  5. Назначить методы объектов — обработчиками событий на элементы интерфейса


В лоб


Самая простая реализация для одного объекта выглядит так:
function InitDomForObject(object){
        //строим DOM, запоминаем ссылки, заполняем начальными значениями
        object.container = $("<div>", {className : "container"});
        object.inputs.name = $("<input>", {value : object.data.name}).appendTo(object.container);
        ...
        //назначаем обработчики
        object.inputs.name.change($.proxy(object, "SetName"));
        ...
}


Очевидные недостатки этой реализаци:

  1. Жесткая связанность верстки и JS-кода
  2. Огромное количество кода построения DOM и назначения обработчиков событий (для сложных интерфейсов)
  3. При таком подходе построение DOM займет у браузера слишком много времени, ведь мы в цикле для каждого объекта будем вызывать по несколько раз createElement, setAttribute и appendChild, а это довольно «тяжелые» операции


Шаблоны


Столкнувшись с такими трудностями нам тут же приходит в голову использовать шаблоны, и не генерировать DOM вручную, ведь нам хорошо известно, что если мы построим интерфейс в виде строки с HTML-кодом, то он обработается браузером почти мгновенно.

Мы построили HTML (допустим, на стороне сервера), и вывели его в браузер, но столкнулись с тем что нам придется искать элементы в большом DOM дереве.
По причине того, что нам нужно назначить обработчики на элементы – мы не можем использовать «ленивую» инициализацию, нам все равно придется найти абсолютно все элементы интерфейса при загрузке приложения.
Допустим все наши объекты разных типов имеют сквозную нумерацию, и собраны в массиве Objects.
Теперь у нас есть два пути:

Вариант А

Искать элементы по классам, записав ID объекта в атрибуте rel.
HTML-представление одного объекта будет выглядеть так:
<div class="container" rel="12345">
      <input type="text" class="objectName" rel="12345" />
</div>


Затем для каждого типа элементов интерфейса назначаем примерно такой обработчик:
$(".objectName").change(function(){
       var id = $(this).attr("rel"); //узнаем, к какому объекту принадлежит
       Objects[id].SetName(); //вызываем функцию
});


А если нам по какой-то причине захочется еще и сохранить ссылки на каждый в отдельности элемент интерфейса, нам вообще придется писать жуткий цикл по всему массиву объектов: это долго и неудобно.

Вариант Б

Конечно же мы знаем, что поиск элементов по id гораздо быстрее!
Так как наши id должны быть уникальны, мы можем использовать например такой формат «name_12345» («роль_идентификатор»):
<div id="container_12345" class="container">
       <input type="text" id="name_12345" class="objectName" />
</div>


Назначение обработчиков будет выглядеть почти также:
$(".objectName").change(function(){
      var id = this.id.split("_")[1];//узнаем, к какому объекту принадлежит
      Objects[id].SetName(); //вызываем функцию
});


Так как теперь все элементы можно найти по ID, а обработчики уже назначены – мы вполне можем не собирать все ссылки сразу, а делать это по необходимости («лениво»), реализовав где-нибудь в базовом прототипе всех наших объектов метод GetElement:
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];
}


Поиск по ID и так очень быстрый, но кэш еще никогда никому не мешал. Однако если вы собираетесь удалять элементы из дерева, учтите — что пока на них есть ссылки, сборщик мусора до них не доберется.

У нас останется только одна проблема: большое количество кода назначения обработчиков событий, ведь для каждого типа элементов интерфейса нам придется назначить отдельный обработчик для каждого события! Итого количество назначений будет равно = кол-во объектов Х количество элементов Х количество событий.

Итоговое решение


Вспомним о замечательном свойстве событий в DOM: захват и всплытие (capturing и bubbling):
ведь мы можем назначить обработчики событий на корневом элементе, ведь все равно все события проходят через него!

Мы могли бы использовать метод jQuery.live для этой цели, и пришли бы к тому же, что написано выше: Варианту Б, а именно к большому количеству кода назначения обработчиков.

Вместо этого мы напишем небольшой «роутер» для наших событий. Договоримся начинать все id элементов со специального символа, чтобы исключить элементы, для которых не нужно никаких обработчиков событий. Роутер будет пытаться перенаправить событие объекту, которому «принадлежит» данный элемент, и вызвать у него соответствующий метод.
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); // вызываем метод в контексте объекта, и передаем ему в качестве параметра объект события
       }
    }
}


Пример использования:
<input type="text" id="-Name_12345">


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");
        }
}


Плюсы решения:

  • Не нужно назначать множество отдельных обработчиков, минимум кода
  • Каждое событие автоматически направляется нужному методу объекта, и метод вызывается в нужном контексте
  • Можно в любой момент добавить / удалить элементы интерфейса из верстки, вам потребуется лишь реализовать соответствующие методы у ваших объектов
  • Количество назначений обработчиков равно количеству типов событий ( а не кол-во объектов Х количество элементов Х количество событий )

Минусы:

  • Необходимо особым образом и в больших количествах назначать ID элементам. (Это требует лишь изменения шаблонов)
  • При каждом событии в каждом элементе вызывается наш EventHandler (это почти не снижает производительность, так как ненужные элементы мы сразу отбрасываем, а также вызываем stopPropagation)
  • Нумерация объектов должна быть сквозной (можно разделять массив Objects на несколько — по одному для каждого из типов объектов, или же вообще ввести отдельные внутренние порядковые индексы, вместо использования тех же ID, что и на сервере)

Другие варианты



Быстрое решение

Если бы наш интерфейс был стандартным и простым (т.е. использовал только стандартные элементы управления), мы бы применили обычную методику связывания данных, например jQuery DataLink:
$("#container").link(object,
        {
                name : "objectName"
        }
);

При изменении свойства объекта – меняется значение текстового поля и наоборот.
Однако в реальности мы чаще используем нестандартные элементы интерфейса, и более сложные зависимости, нежели «Один элемент интерфейса к одному свойству объекта». Изменение одного поля может затронуть сразу несколько свойств, причем у разных объектов.

К примеру, если у нас есть пользователь (UserA) с какими-то правами, который принадлежит группе, а также элемент в котором можно выбрать группу (GroupA или GroupB).
Тогда изменение выбора в этом списке повлечет множество других изменений:
В данных:
  • Изменится свойство UserA.group
  • Объект UserA будет удален из массива GroupA.users
  • Объект UserB будет удален из массива GroupB.users
  • Изменится массив UserA.permissions

В интерфейсе:
  • Изменится вид списка прав пользователя
  • Изменятся значения счетчиков, показывающих количество пользователей в группе

И т.д.
Такие сложные зависимости не получится легко разрешить. Как раз в данном случае и подойдет описанный выше метод.

Аналогичное решение

Подобный подход применен вКонтакте: каждому элементу события назначаются через соответствующие атрибуты (onclick, onmouseover и т.д.). Только шаблон собирается на стороне клиента, а не сервера.
Однако обработка событий не делегируется какому-либо объекту:
<div class="dialogs_row" id="im_dialog78974230" onclick="IM.selectDialog(78974230); return false;">

Вместо этого вызываются методы глобальных объектов, что не совсем хорошо, если в приложении, например, используется ООП-подход.

Мы могли бы изменить этот принцип, все же направляя события нужным методам, но выглядело бы это не очень красиво:
<div class="dialogs_row" id="im_dialog78974230" onclick="Dialog.prototype.select.call(Objects[78974230], event); return false;">


Вместо этого мы можем адаптировать нашу функцию-роутер под такой подход:
<input type="text" id="Name_12345" onblur="return Route(event);">

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); // вызываем метод в контексте объекта, и передаем ему в качестве параметра объект события
       }
}

Это избавит нас от обработки большого числа событий на каждый «чих» пользователя, но заставит описывать в шаблоне каждого элемента нужные ему события, да еще и inline — кодом.

Какое из этих зол наименьшее, и соответственно какой из методов выбрать — зависит от контекста и конкретной задачи.
Tags:
Hubs:
Total votes 28: ↑24 and ↓4+20
Comments14

Articles