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

Всплытие событий позволяет реализовать один из самых важных приёмов разработки – делегирование.

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

Пример «Ба Гуа»

Рассмотрим пример – диаграмму «Ба Гуа». Это таблица, отражающая древнюю китайскую философию.

Вот она:

Её HTML (схематично):

<table>
  <tr>
    <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
  </tr>
  <tr>
    <td>...<strong>Northwest</strong>...</td>
    <td>...</td>
    <td>...</td>
  </tr>
  <tr>...еще 2 строки такого же вида...</tr>
  <tr>...еще 2 строки такого же вида...</tr>
</table>

В этой таблице всего 9 ячеек, но могло быть и 99, и даже 9999, не важно.

Наша задача – реализовать подсветку ячейки <td> при клике.

Вместо того, чтобы назначать обработчик для каждой ячейки, которых может быть очень много – мы повесим единый обработчик на элемент <table>.

Он будет использовать event.target, чтобы получить элемент, на котором произошло событие, и подсветить его.

Код будет таким:

var selectedTd;

table.onclick = function(event) {
  var target = event.target; // где был клик?

  if (target.tagName != 'TD') return; // не на TD? тогда не интересует

  highlight(target); // подсветить TD
};

function highlight(node) {
  if (selectedTd) {
    selectedTd.classList.remove('highlight');
  }
  selectedTd = node;
  selectedTd.classList.add('highlight');
}

Такому коду нет разницы, сколько ячеек в таблице. Обработчик всё равно один. Я могу добавлять, удалять <td> из таблицы, менять их количество – моя подсветка будет стабильно работать, так как обработчик стоит на <table>.

Однако, у текущей версии кода есть недостаток.

Клик может быть не на том теге, который нас интересует, а внутри него.

В нашем случае, если взглянуть на HTML таблицы внимательно, видно, что ячейка содержит вложенные теги, например <strong>:

<td>
  <strong>Northwest</strong>
  ...Metal..Silver..Elders...
</td>

Естественно, клик может произойти внутри <td>, на элементе <strong>. Такой клик будет пойман единым обработчиком, но target у него будет не <td>, а <strong>:

Внутри обработчика table.onclick мы должны по event.target разобраться, в каком именно <td> был клик.

Для этого мы, используя ссылку parentNode, будем идти вверх по иерархии родителей от event.target и выше и проверять:

  • Если нашли <td>, значит это то что нужно.
  • Если дошли до элемента table и при этом <td> не найден, то наверное клик был вне <td>, например на элементе заголовка таблицы.

Улучшенный обработчик table.onclick с циклом while, который это делает:

table.onclick = function(event) {
  var target = event.target;

  // цикл двигается вверх от target к родителям до table
  while (target != table) {
    if (target.tagName == 'TD') {
      // нашли элемент, который нас интересует!
      highlight(target);
      return;
    }
    target = target.parentNode;
  }

  // возможна ситуация, когда клик был вне <td>
  // если цикл дошёл до table и ничего не нашёл,
  // то обработчик просто заканчивает работу
}
На заметку:

Кстати, в проверке while можно бы было использовать this вместо table:

while (target != this) {
  // ...
}

Это тоже будет работать, так как в обработчике table.onclick значением this является текущий элемент, то есть table.

Можно для этого использовать и метод closest, при поддержке браузером:

table.onclick = function(event) {
  var target = event.target;

  var td = target.closest('td');
  if (!td) return; // клик вне <td>, не интересует

  // если клик на td, но вне этой таблицы (возможно при вложенных таблицах)
  // то не интересует
  if (!table.contains(td)) return;

  // нашли элемент, который нас интересует!
  highlight(td);
}

Применение делегирования: действия в разметке

Обычно делегирование – это средство оптимизации интерфейса. Мы используем один обработчик для схожих действий на однотипных элементах.

Выше мы это делали для обработки кликов на <td>.

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

Например, нам нужно сделать меню с разными кнопками: «Сохранить», «Загрузить», «Поиск» и т.д. И есть объект с соответствующими методами: save, load, search и т.п…

Первое, что может прийти в голову – это найти каждую кнопку и назначить ей свой обработчик среди методов объекта.

Но более изящно решить задачу можно путем добавления одного обработчика на всё меню, а для каждой кнопки в специальном атрибуте, который мы назовем data-action (можно придумать любое название, но data-* является валидным в HTML5), укажем, что она должна вызывать:

<button data-action="save">Нажмите, чтобы Сохранить</button>

Обработчик считывает содержимое атрибута и выполняет метод. Взгляните на рабочий пример:

<div id="menu">
  <button data-action="save">Сохранить</button>
  <button data-action="load">Загрузить</button>
  <button data-action="search">Поиск</button>
</div>

<script>
  function Menu(elem) {
    this.save = function() {
      alert( 'сохраняю' );
    };
    this.load = function() {
      alert( 'загружаю' );
    };
    this.search = function() {
      alert( 'ищу' );
    };

    var self = this;

    elem.onclick = function(e) {
      var target = e.target;
      var action = target.getAttribute('data-action');
      if (action) {
        self[action]();
      }
    };
  }

  new Menu(menu);
</script>

Обратите внимание, как используется трюк с var self = this, чтобы сохранить ссылку на объект Menu. Иначе обработчик просто бы не смог вызвать методы Menu, потому что его собственный this ссылается на элемент.

Что в этом случае нам дает использование делегирования событий?

  • Не нужно писать код, чтобы присвоить обработчик каждой кнопке. Меньше кода, меньше времени, потраченного на инициализацию.
  • Структура HTML становится по-настоящему гибкой. Мы можем добавлять/удалять кнопки в любое время.
  • Данный подход является семантичным. Также можно использовать классы .action-save, .action-load вместо атрибута data-action.

Итого

Делегирование событий – это здорово! Пожалуй, это один из самых полезных приёмов для работы с DOM. Он отлично подходит, если есть много элементов, обработка которых очень схожа.

Алгоритм:

  1. Вешаем обработчик на контейнер.
  2. В обработчике: получаем event.target.
  3. В обработчике: если event.target или один из его родителей в контейнере (this) – интересующий нас элемент – обработать его.

Зачем использовать:

  • Упрощает инициализацию и экономит память: не нужно вешать много обработчиков.
  • Меньше кода: при добавлении и удалении элементов не нужно ставить или снимать обработчики.
  • Удобство изменений: можно массово добавлять или удалять элементы путём изменения innerHTML.

Конечно, у делегирования событий есть свои ограничения.

  • Во-первых, событие должно всплывать. Нельзя, чтобы какой-то промежуточный обработчик вызвал event.stopPropagation() до того, как событие доплывёт до нужного элемента.
  • Во-вторых, делегирование создает дополнительную нагрузку на браузер, ведь обработчик запускается, когда событие происходит в любом месте контейнера, не обязательно на элементах, которые нам интересны. Но обычно эта нагрузка настолько пустяковая, её даже не стоит принимать во внимание.

Задачи

важность: 5

Поставьте обработчик click на контейнере. Он должен проверять, произошел ли клик на кнопке удаления (target), и если да, то удалять соответствующий ей DIV.

Открыть решение в песочнице.

Дан список сообщений. Добавьте каждому сообщению кнопку для его удаления.

Используйте делегирование событий. Один обработчик для всего.

В результате, должно работать вот так(кликните на крестик):

Открыть песочницу для задачи.

важность: 5

Схема решения

Дерево устроено как вложенный список.

Клики на все элементы можно поймать, повесив единый обработчик onclick на внешний UL.

Как поймать клик на заголовке? Элемент LI является блочным, поэтому нельзя понять, был ли клик на тексте, или справа от него.

Например, ниже – участок дерева с выделенными рамкой узлами. Кликните справа от любого заголовка. Видите, клик ловится? А лучше бы такие клики (не на тексте) игнорировать.

<style>
  li {
    border: 1px solid green;
  }
</style>

<ul onclick="alert(event.target)">
  <li>Млекопетающие
    <ul>
      <li>Коровы</li>
      <li>Ослы</li>
      <li>Собаки</li>
      <li>Тигры</li>
    </ul>
  </li>
</ul>

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

Один из способов это поправить – обернуть заголовки в дополнительный элемент SPAN, и обрабатывать только клики внутри SPAN'ов, получать по SPAN'у его родителя LI и ставить ему класс открыт/закрыт.

Напишите для этого JavaScript-код.

Оборачиваем заголовки в SPAN

Следующий код ищет все LI и оборачивает текстовые узлы в SPAN.

var treeUl = document.getElementsByTagName('ul')[0];

var treeLis = treeUl.getElementsByTagName('li');

for (var i = 0; i < treeLis.length; i++) {
  var li = treeLis[i];

  var span = document.createElement('span');
  li.insertBefore(span, li.firstChild); // добавить пустой SPAN
  span.appendChild(span.nextSibling); // переместить в него заголовок
}

Теперь можно отслеживать клики на заголовках.

Так выглядит дерево с обёрнутыми в SPAN заголовками и делегированием:

<style>
  span {
    border: 1px solid red;
  }
</style>

<ul onclick="alert(event.target.tagName)">
  <li><span>Млекопетающие</span>
    <ul>
      <li><span>Коровы</span></li>
      <li><span>Ослы</span></li>
      <li><span>Собаки</span></li>
      <li><span>Тигры</span></li>
    </ul>
  </li>
</ul>

Так как SPAN – инлайновый элемент, он всегда такого же размера как текст. Да здравствует SPAN!

В реальной жизни дерево, скорее всего, будет сразу со SPAN: если HTML-код дерева генерируется на сервере, то это несложно, если дерево генерируется в JavaScript – тем более просто.

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

Для делегирования нужно по клику понять, на каком узле он произошел.

В нашем случае у SPAN нет детей-элементов, поэтому не нужно подниматься вверх по цепочке родителей. Достаточно просто проверить event.target.tagName == 'SPAN', чтобы понять, где был клик, и спрятать потомков.

var tree = document.getElementsByTagName('ul')[0];

tree.onclick = function(event) {
  var target = event.target;

  if (target.tagName != 'SPAN') {
    return; // клик был не на заголовке
  }

  var li = target.parentNode; // получить родительский LI

  // получить UL с потомками -- это первый UL внутри LI
  var childrenContainer = li.getElementsByTagName('ul')[0];

  if (!childrenContainer) return; // потомков нет -- ничего не надо делать

  // спрятать/показать (можно и через CSS-класс)
  childrenContainer.hidden = !childrenContainer.hidden;
}

Выделение узлов жирным при наведении делается при помощи CSS-селектора :hover.

Открыть решение в песочнице.

Создайте дерево, которое по клику на заголовок скрывает-показывает детей:

Требования:

  • Использовать делегирование.
  • Клик вне текста заголовка (на пустом месте) ничего делать не должен.
  • При наведении на заголовок – он становится жирным, реализовать через CSS.

P.S. При необходимости HTML/CSS дерева можно изменить.

Открыть песочницу для задачи.

важность: 4

Подсказка (обработчик)

  1. Обработчик onclick можно повесить один, на всю таблицу или THEAD. Он будет игнорировать клики не на TH.
  2. При клике на TH обработчик будет получать номер из TH, на котором кликнули (TH.cellIndex) и вызывать функцию sortColumn, передавая ей номер колонки и тип.
  3. Функция sortColumn(colNum, type) будет сортировать.

Подсказка (сортировка)

Функция сортировки:

  1. Переносит все TR из TBODY в массив rowsArr
  2. Сортирует массив, используя rowsArr.sort(compare), функция compare зависит от типа столбца.
  3. Добавляет TR из массива обратно в TBODY

Открыть решение в песочнице.

Сделать сортировку таблицы при клике на заголовок.

Демо:

Требования:

  • Использовать делегирование.
  • Код не должен меняться при увеличении количества столбцов или строк.

P.S. Обратите внимание, тип столбца задан атрибутом у заголовка. Это необходимо, ведь числа сортируются иначе чем строки. Соответственно, код это может использовать.

P.P.S. Вам помогут дополнительные навигационные ссылки по таблицам.

Открыть песочницу для задачи.

Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.