Делегирование событий

На предыдущем занятии мы с вами познакомились с таким важным механизмом как погружение и всплытие событий для вложенных элементов. Так вот, используя механизм всплытия, в JavaScript реализуется такой прием разработки как делегирование событий. Что это такое? Предположим, у нас есть вот такая страница с неким меню:

<!DOCTYPE html>
<html>
<head>
<title>Уроки по JavaScript</title>
<style>
#main_menu {
  margin: 10px; padding: 0; 
  list-style: none; font-size: 24px; 
  width: 200px; background: #eee;  
  cursor: pointer;
}
#main_menu li { margin-bottom: 10px; padding: 5px;}
#main_menu li:hover {background: #FF4EF5;}
</style>
</head>
<body>
<ul id="main_menu">
<li>Открыть</li><li>Сохранить</li>
<li>Рисовать</li><li>Редактировать</li>
<li>Настройки</li>
</ul>
<script>
</script>
</body>
</html>

И мы, естественно, хотим обрабатывать клики по каждому из этих пунктов. Конечно, можно повесить обработчик на каждый элемент li. Но можно сделать лучше: повесить один общий обработчик на элемент ul, который благодаря механизму всплытия будет перехватывать все клики от дочерних элементов li.

Добавим в скрипт следующие строчки:

main_menu.addEventListener("click", function(event) {
      let li = event.target;
      console.log(li.innerHTML);
});

Теперь кликая по элементам li мы в консоли видим соответствующие строчки. Однако, если кликнуть между этими элементами, то сработает обработчик для ul и мы увидим все пункты меню. Это не то, что нам нужно. Поэтому немного перепишем обработчик:

      let li = event.target.closest('li');
      if(li == null) return;

Здесь метод closest вернем ближайший дочерний элемент li, а если его нет, то значение null. Далее, мы проверяем: если li равно null, то завершаем работу обработчика. Обновим документ и видим, что теперь все работает гораздо лучше.

Но нам в нашей задаче нужно не получать содержимое элементов li, а выполнять определенные действия при выборе того или иного пункта меню. Как это лучше сделать? В практике программирования на JavaScript существует один очень эффективный и элегантный способ решить эту задачу. Для этого нам понадобятся нестандартные атрибуты, начинающиеся с префикса data-. Добавим к нашим тегам li следующие параметры:

<li data-command="open">Открыть</li><li data-command="save">Сохранить</li>
<li data-command="draw">Рисовать</li><li data-command="edit">Редактировать</li>
<li data-command="customize">Настройки</li>

А в обработчике будем читать этот атрибут:

console.log(li.dataset.command);

Теперь при клике по пункту меню будем получать команду, которую следует выполнить. Для удобства все эти команды можно прописать как методы некоторого объекта:

let menuActs = { 
     open() {alert("open...");},
     save() {alert("save...");},
};

И вызывать их вот таким универсальным кодом:

    let act = li.dataset.command;
    if(act && menuActs[act] !== undefined) menuActs[act]();

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

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

Кстати, используя прием с нестандартными атрибутами, в JavaScript удобно реализовывать обработчики общего назначения. Например, для отображения или сокрытия с экрана браузера некоторого элемента. Для примера, предположим, что у нас есть вот такая кнопка:

<p><input type="button" data-toggle-id="main_menu" value="Показать/Скрыть меню" />

с атрибутом data-toggle-menu и значением main_menu. Создадим универсальный обработчик, который будет использовать этот атрибут, чтобы показать или скрыть элемент с указанным id:

document.addEventListener("click", function(event) {
   let id = event.target.dataset.toggleId;
   if(!id) return;
   let elem = document.getElementById(id);
   if(!elem) return;
   elem.hidden = !elem.hidden;
});

Обратите внимание, что общий обработчик мы вешаем на объект document, который является корневым для DOM-дерева, то есть, все элементы внутри HTML-документа являются дочерними для этого объекта. Поэтому, когда событие всплывает до document, мы берем ссылку event.target на элемент, в котором событие произошло и смотрим значение его атрибута data-toggle-id. Если оно существует, то ищем элемент с указанным id, и если такой элемент находится, то меняем его видимость на противоположное значение. В результате наше меню то скрывается, то показывается.

Здесь может возникнуть вопрос: почему мы повесили универсальный обработчик через метод addEventListener, а не через свойство onclick объекта document? Дело в том, что на свойстве onclick уже может висеть какой-либо нужный обработчик и меняя его можно порушить работу сайта. Тогда как метод addEventListener лишь добавляет еще один обработчик к уже существующим и никаких дополнительных проблем это не должно вызывать. Особенно так удобно делать при разработке больших проектов, работая в команде: каждый программист может через addEventListener назначить свои обработчики и это никак не повлияет на работоспособность всего проекта.

Во второй части этого занятия рассмотрим события, которые браузер выполняет автоматически. Что это за события? Например, если мы вместо кнопки разместим вот такую ссылку:

<p><a href="ex1.htm" data-toggle-id="main_menu">Показать/Скрыть меню</a>

то наш скрипт перестанет работать, так как браузер при клике будет выполнять переход к странице ex1.htm, то есть, перегружать текущий документ. Как сделать так, чтобы этого не происходило, то есть отменить действие браузера по умолчанию? Здесь есть два способа: наше событие onclick должно вернуть значение false:

onclick="return false;"

или же выполнить метод event.preventDefault() в обработчике события:

let a = document.querySelector('a');
a.onclick = function(event) {
   event.preventDefault();
}

Когда может понадобиться такое прерывание действия браузера по умолчанию? Предположим, что мы создаем контекстное меню для всего документа:

document.addEventListener("contextmenu", function(event) {
      console.log("Контекстное меню документа");
});

Нажимая на правую кнопку мыши, мы увидим в консоли сообщение и контекстное меню по умолчанию от браузера. Но мы не хотим, чтобы стандартное меню отображалось. Для этого добавим в обработчик строку:

event.preventDefault();

Теперь щелкая правой кнопкой мыши, будем видеть только сообщение в консоли.

Далее, нам говорят, что нужно еще сделать отдельное контекстное меню для элементов главного меню. И мы добавляем в наш скрипт такие строчки:

main_menu.oncontextmenu = function(event) {
   console.log("Контекстное меню главного меню");
   event.preventDefault();
}

Но при щелчке правой кнопкой мыши, мы теперь видим два контекстных меню, так как событие oncontextmenu всплыло до уровня document и там тоже сработал обработчик этого события. Естественным желанием будет остановить всплытие события:

event.stopPropagation();

и вроде бы все заработало как надо. Только теперь обработчики верхнего уровня не поймают это событие. А что, если среди них есть тот, что считает статистику вызова контекстных меню на странице? Лучшим решением будет в обработчике верхнего уровня отследить: было ли уже вызвано контекстное меню в обработчике нижнего уровня и если так, то ничего не делать. Это можно реализовать так:

  • убрать строчку event.stopPropagation();
  • и добавить первой строкой if (event.defaultPrevented) return; в document.addEventListener

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

event.defaultPrevented

мы можем узнать было ли в дочерних элементах вызван метод

event.preventDefault();

и если да, то defaultPrevented принимает значение true. Иначе, оно будет равно false. Как видите, все достаточно просто.

Последнее, что отметим на этом занятии, в методе addEventListener можно использовать вот такой необязательный третий аргумент:

{passive: true}

который сигнализирует браузеру, что добавляемый обработчик события не собирается прерывать действие по умолчанию браузера. Например:

document.addEventListener("contextmenu", function(event) {
      if (event.defaultPrevented) return;
      console.log("Контекстное меню документа");
      event.preventDefault();
}, {passive: true});

Здесь возникнет ошибка, так как мы вызываем event.preventDefault(), хотя указали, что не будем этого делать. Зачем вообще нужно заранее говорить JavaScript-машине, что мы не собираемся прерывать обработку события? Дело в том, что некоторые действия, вроде скроллинга документа, особенно на смартфонах, желательно выполнять быстро и без задержек. Чтобы увеличить время реакции мы можем сразу сказать, что можно делать скроллинг, не дожидаясь окончания обработки текущего события в нашем скрипте. В некоторых браузерах (Firefox, Chrome) это свойство {passive: true} установлено сразу по умолчанию и это следует учитывать при написании программ.

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

Видео по теме