english version

Произвольные события

Стандартный компонент

Современное веб-приложение - это взаимодействие компонентов, как распространённых (календарь, дерево), так и специфических – нужных в одном единственном месте.

Для начала я рассмотрю стандартный компонент – календарь. Сам по себе календарь прост, и любой JavaScript разработчик в состоянии быстро его написать.

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

Уже по этой картинке видно, что вариантов реализации много, в реальности их намного больше. Чтобы сделать приятно и менеджерам, и серверным программистам вовсе необязательно менять тот код календаря, который уже написан.

Объект CallBacks

CallBacks - это объект, у которого есть всего 2 метода add и dispatch. Первый метод позволяет повесить событие обработчик на событие, а второй - проинициализировать событие.

CallBacks.add(имя события, ссылка на функцию обработчик, объект на котором слушать событие);
CallBacks.dispatch(имя события, объект, событие)          
          

Внутри функции add у объекта берется уникальный идентификатор (если такого нет, то генерируется). Генерируется ключ на основе идентификатора и имени события, и функция или функции обработчики запоминаются под этим ключом.

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

CallBacks.add = function (type, listener, object) {
  var key = this.getId(object) + type;
  if (typeof(this.listeners[key]) == 'undefined') {
      this.listeners[key] = [];
  }
  this.listeners[key].push(listener);
};
CallBacks.dispatch = function (type, object, event) {
  var key = this.getId(object) + type;
  if (typeof(this.listeners[key]) == 'undefined') {
      return;
  }
  this.execListeners(this.listeners[key], event);
};
          

Как это работает

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

Сам календарь при изменениях инициализирует событие на HTML объекте, из которого он создан, а в качестве события передает новую дату.

CallBacks.dispatch('change', this.element /* ссылка на HTML узел */, newDate);
          

Теперь любая обертка должна только иметь ссылку на HTML узел, из которого сделан календарь, и она сможет реагировать на его изменения.

CallBacks.add('change', function () {…}, calendar /* ссылка на HTML узел */);
          

Чтобы открывать календарь по нажатию на дату или кнопку «Календарь», нужно сделать обратное действие.

CallBacks.add('open',  this.open, this.element /* ссылка на HTML узел */); 
CallBacks.dispatch('open', calendar /* ссылка на HTML узел */); // без объекта событие
          

Насыщенные интерфейсы

Если в случае с одним календарем можно было сделать на операторах if, то в таком интерфейсе это сделать крайне сложно. Взаимосвязи:

  • Два верхних блока 1 и 2 определяют коэффициенты для цен, блок 5.
  • Календарь, блок 3, определяет начало периода для блока 4.
  • Блок 5 задает длительность периода для блока 4.
  • Блок 6 собирает параметры всех блоков воедино.

Поведение этого интерфейса много раз менялось и будет меняться. Тут есть уже рассмотренный календарь, только теперь он не только имеет изменённый интерфейс, но и должен сообщать о своем состоянии окружающим объектам.

Для обмена событиями с соседними компонентами необходимо найти объект, который эти все компоненты видят. Обычно сложные интерфейсы у меня имеют корневой объект – namespace. Можно вешать и кидать события на этот корневой объект.

CallBacks.dispatch('some-event', Price /* Корневой объект этого интерфейса */, event);
CallBacks.add('some-event', function () {…}, Price /* Корневой объект этого интерфейса */);          
          

Область видимости событий

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

Я использую 2 способа разграничение видимости:

  • Префикс или постфикс в имени события.
  • Генерировать событие на различных объектах.

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

CallBacks.dispatch('some-event' + key,  …);
CallBacks.add('some-event' + key,  …);         
          

Этот способ хорош, когда взаимодействующие компоненты «размазаны» по странице и сложно найти объединяющий их HTML узел.

Во втором случае выделяется HTML узел, объединяющий нужные компоненты, и события посылаются на этот узел (хотя это может быть и JS объект).

CallBacks.dispatch('some-event' , HTMLNode, event);
CallBacks.add('some-event' , function(){…}, HTMLNode);        
          

Проксирующие объекты

Когда я имею дело с общими компонентами (тот же календарь), я не могу изменять имена и объекты, на которые календарь бросает и с которых слушает события. Для всех мест, где он используется, это один и тот же объект и имя события. Мне кажется, логичнее всего слушать и кидать события на HTML узел, на котором он проинициализирован.

Чтобы встраивать такие компоненты в интерфейсы, я использую проксирующие объекты. Это объекты, которые, с одной стороны, знают поведение компонента, который они проксируют (календарь). С другой стороны - знают контекст, в который надо встроить этот компонент.

Проксирующий объект ловит все события среды, предназначенные для проксируемого объекта, и конвертирует их в понятный для него вид и наоборот.

function proxyChangeCalendar(event){
    CallBacks.dispatch('change-calendar', Price, event);
}
CallBacks.add('change', proxyChangeCalendar, calendar);       
          

Это позволяет легко добавлять календарь в любые интерфейсы.

Инициализация

Если использовать асинхронную инициализацию компонентов, то возникает сложность синхронизации начальных состояний. Если состояние компонента зависит от состояния другого компонента (в том числе и от начального), то надо обеспечить возможность получения этого состояния.

Когда компонент А проинициализировался и бросил событие с данными о своем начальном состоянии, а компонента Б еще не успел повесить обработчик, то это приведет к ошибочному поведению.

Я нашел два возможных решения этой проблемы:

  • События флаги (спасибо Федору Голубеву).
  • Принудительное срабатывание события.

Идея первого способа в том, чтобы объекты могли проинициализировать события флаги. Если компонент кидает событие флаг, то объект CallBacks не просто выполняет обработчики, но и запоминает event с которым оно брошено.

Когда компонент говорит, что хочет слушать событие, и оно является флагом, то при наличии event этого события у объекта CallBacks он сразу выполняет переданную функцию слушатель, отдавая на вход сохраненное событие.

Для события флага можно сделать отдельный метод генерации события, для указания функции слушателя можно ничего не менять.

CallBacks.dispatchFlag(…);
CallBacks.add(…);       
          

Недостаток метода в том, что он не может быть применен напрямую в случае, когда объект слушает состояние нескольких однотипных объектов. Однотипные объекты будут генерировать события с одинаковым ключом (id объекта, на который кидается событие и имя события) и будут конфликтовать.

Суть второго метода: дать компонентам слушать события, в ответ на которые они будут генерировать события со своим состоянием. Если компонент А уже проинициализировался и сгенерировал событие со своим состоянием, то компонент Б при инициализации генерирует событие, которое слушает компонент А и в ответ на которое еще раз пошлет свое состояние.

Если же компонент Б проинициализировался раньше, то компонент А еще не успеет начать слушать событие, заставляющее его отослать свое состояние, и ничего не произойдет, а компонент Б уже будет слушать событие, которое сгенерирует компонент А при инициализации.

Результат

Возможно, сложные на первый взгляд приемы позволили мне легко переносить компоненты из проекта в проект, «развязать» компоненты (удаление компонента не приводит к неработоспособности всего кода), не бояться модернизаций и изменений.