Привет Хабр! В этой статье я рассмотрю один из вариантов построения архитектуры клиент-серверного веб-приложения с точки зрения связывания данных. Этот вариант не претендует на оригинальность, но лично мне позволил значительно сократить время на разработку, а также оптимизировать время загрузки.
Допустим у нас есть крупный веб-интерфейс, который должен отображать и позволять пользователю взаимодействовать с несколькими сотнями элементов различных типов одновременно.
Для каждого типа объекта у нас есть свой класс, и мы, естественно, хотим связать действия пользователя в интерфейсе с методами этих классов.
В данном случае мы рассмотрим простой пример – у объекта есть название (свойство name), а интерфейс управления объектом представляет собой текстовое поле, куда это название вводится. При изменении поля мы хотим что бы у объекта менялось свойство, и новое значение отправлялось на сервер (метод SetName).
Обычно типовая последовательность инициализации приложения выглядит так:
Самая простая реализация для одного объекта выглядит так:
Столкнувшись с такими трудностями нам тут же приходит в голову использовать шаблоны, и не генерировать DOM вручную, ведь нам хорошо известно, что если мы построим интерфейс в виде строки с HTML-кодом, то он обработается браузером почти мгновенно.
Мы построили HTML (допустим, на стороне сервера), и вывели его в браузер, но столкнулись с тем что нам придется искать элементы в большом DOM дереве.
По причине того, что нам нужно назначить обработчики на элементы – мы не можем использовать «ленивую» инициализацию, нам все равно придется найти абсолютно все элементы интерфейса при загрузке приложения.
Допустим все наши объекты разных типов имеют сквозную нумерацию, и собраны в массиве Objects.
Теперь у нас есть два пути:
Искать элементы по классам, записав ID объекта в атрибуте rel.
HTML-представление одного объекта будет выглядеть так:
Затем для каждого типа элементов интерфейса назначаем примерно такой обработчик:
А если нам по какой-то причине захочется еще и сохранить ссылки на каждый в отдельности элемент интерфейса, нам вообще придется писать жуткий цикл по всему массиву объектов: это долго и неудобно.
Конечно же мы знаем, что поиск элементов по id гораздо быстрее!
Так как наши id должны быть уникальны, мы можем использовать например такой формат «name_12345» («роль_идентификатор»):
Назначение обработчиков будет выглядеть почти также:
Так как теперь все элементы можно найти по ID, а обработчики уже назначены – мы вполне можем не собирать все ссылки сразу, а делать это по необходимости («лениво»), реализовав где-нибудь в базовом прототипе всех наших объектов метод GetElement:
Поиск по ID и так очень быстрый, но кэш еще никогда никому не мешал. Однако если вы собираетесь удалять элементы из дерева, учтите — что пока на них есть ссылки, сборщик мусора до них не доберется.
У нас останется только одна проблема: большое количество кода назначения обработчиков событий, ведь для каждого типа элементов интерфейса нам придется назначить отдельный обработчик для каждого события! Итого количество назначений будет равно =кол-во объектов Х количество элементов Х количество событий .
Вспомним о замечательном свойстве событий в DOM: захват и всплытие (capturing и bubbling):
ведь мы можем назначить обработчики событий на корневом элементе, ведь все равно все события проходят через него!
Мы могли бы использовать метод jQuery.live для этой цели, и пришли бы к тому же, что написано выше: Варианту Б, а именно к большому количеству кода назначения обработчиков.
Вместо этого мы напишем небольшой «роутер» для наших событий. Договоримся начинать все id элементов со специального симв��ла, чтобы исключить элементы, для которых не нужно никаких обработчиков событий. Роутер будет пытаться перенаправить событие объекту, которому «принадлежит» данный элемент, и вызвать у него соответствующий метод.
Пример использования:
Если бы наш интерфейс был стандартным и простым (т.е. использовал только стандартные элементы управления), мы бы применили обычную методику связывания данных, например jQuery DataLink:
При изменении свойства объекта – меняется значение текстового поля и наоборот.
Однако в реальности мы чаще используем нестандартные элементы интерфейса, и более сложные зависимости, нежели «Один элемент интерфейса к одному свойству объекта». Изменение одного поля может затронуть сразу несколько свойств, причем у разных объектов.
К примеру, если у нас есть пользователь (UserA) с какими-то правами, который принадлежит группе, а также элемент в котором можно выбрать группу (GroupA или GroupB).
Тогда изменение выбора в этом списке повлечет множество других изменений:
В данных:
В интерфейсе:
И т.д.
Такие сложные зависимости не получится легко разрешить. Как раз в данном случае и подойдет описанный выше метод.
Подобный подход применен вКонтакте: каждому элементу события назначаются через соответствующие атрибуты (onclick, onmouseover и т.д.). Только шаблон собирается на стороне клиента, а не сервера.
Однако обработка событий не делегируется какому-либо объекту:
Вместо этого вызываются методы глобальных объектов, что не совсем хорошо, если в приложении, например, используется ООП-подход.
Мы могли бы изменить этот принцип, все же направляя события нужным методам, но выглядело бы это не очень красиво:
Вместо этого мы можем адаптировать нашу функцию-роутер под такой подход:
Это избавит нас от обработки большого числа событий на каждый «чих» пользователя, но заставит описывать в шаблоне каждого элемента нужные ему события, да еще и inline — кодом.
Какое из этих зол наименьшее, и соответственно какой из методов выбрать — зависит от контекста и конкретной задачи.
Проблема
Допустим у нас есть крупный веб-интерфейс, который должен отображать и позволять пользователю взаимодействовать с несколькими сотнями элементов различных типов одновременно.
Для каждого типа объекта у нас есть свой класс, и мы, естественно, хотим связать действия пользователя в интерфейсе с методами этих классов.
В данном случае мы рассмотрим простой пример – у объекта есть название (свойство name), а интерфейс управления объектом представляет собой текстовое поле, куда это название вводится. При изменении поля мы хотим что бы у объекта менялось свойство, и новое значение отправлялось на сервер (метод SetName).
Обычно типовая последовательность инициализации приложения выглядит так:
- Проинициализировать все объекты
- Построить DOM-дерево интерфейса
- Получить ссылки на ключевые элементы интерфейса (контейнер объекта, форма редактирования объекта и т.д.)
- Проинициализировать интерфейс текущими значениями свойств объектов
- Назначить методы объектов — обработчиками событий на элементы интерфейса
В лоб
Самая простая реализация для одного объекта выглядит так:
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"));
...
}Очевидные недостатки этой реализаци:
- Жесткая связанность верстки и JS-кода
- Огромное количество кода построения DOM и назначения обработчиков событий (для сложных интерфейсов)
- При таком подходе построение 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 — кодом.
Какое из этих зол наименьшее, и соответственно какой из методов выбрать — зависит от контекста и конкретной задачи.
