Всплытие и перехват событий позволяет реализовать один из самых важных приёмов разработки – делегирование.
Идея в том, что если у нас есть много элементов, события на которых нужно обрабатывать похожим образом, то вместо того, чтобы назначать обработчик каждому, мы ставим один обработчик на их общего предка.
Из него можно получить целевой элемент event.target
, понять на каком именно потомке произошло событие и обработать его.
Рассмотрим пример – диаграмму Ба-Гуа. Это таблица, отражающая древнюю китайскую философию.
Вот она:
Её HTML (схематично):
<table>
<tr>
<th colspan="3">Квадрат <em>Bagua</em>: Направление, Элемент, Цвет, Значение</th>
</tr>
<tr>
<td>...<strong>Северо-Запад</strong>...</td>
<td>...</td>
<td>...</td>
</tr>
<tr>...ещё 2 строки такого же вида...</tr>
<tr>...ещё 2 строки такого же вида...</tr>
</table>
В этой таблице всего 9 ячеек, но могло бы быть и 99, и даже 9999, не важно.
Наша задача – реализовать подсветку ячейки <td>
при клике.
Вместо того, чтобы назначать обработчик onclick
для каждой ячейки <td>
(их может быть очень много) – мы повесим «единый» обработчик на элемент <table>
.
Он будет использовать event.target
, чтобы получить элемент, на котором произошло событие, и подсветить его.
Код будет таким:
let selectedTd;
table.onclick = function(event) {
let target = event.target; // где был клик?
if (target.tagName != 'TD') return; // не на TD? тогда не интересует
highlight(target); // подсветить TD
};
function highlight(td) {
if (selectedTd) { // убрать существующую подсветку, если есть
selectedTd.classList.remove('highlight');
}
selectedTd = td;
selectedTd.classList.add('highlight'); // подсветить новый td
}
Такому коду нет разницы, сколько ячеек в таблице. Мы можем добавлять, удалять <td>
из таблицы динамически в любое время, и подсветка будет стабильно работать.
Однако, у текущей версии кода есть недостаток.
Клик может быть не на теге <td>
, а внутри него.
В нашем случае, если взглянуть на HTML-код таблицы внимательно, видно, что ячейка <td>
содержит вложенные теги, например <strong>
:
<td>
<strong>Северо-Запад</strong>
...
</td>
Естественно, если клик произойдёт на элементе <strong>
, то он станет значением event.target
.
Внутри обработчика table.onclick
мы должны по event.target
разобраться, был клик внутри <td>
или нет.
Вот улучшенный код:
table.onclick = function(event) {
let td = event.target.closest('td'); // (1)
if (!td) return; // (2)
if (!table.contains(td)) return; // (3)
highlight(td); // (4)
};
Разберём пример:
- Метод
elem.closest(selector)
возвращает ближайшего предка, соответствующего селектору. В данном случае нам нужен<td>
, находящийся выше по дереву от исходного элемента. - Если
event.target
не содержится внутри элемента<td>
, то вызов вернётnull
, и ничего не произойдёт. - Если таблицы вложенные,
event.target
может содержать элемент<td>
, находящийся вне текущей таблицы. В таких случаях мы должны проверить, действительно ли это<td>
нашей таблицы. - И если это так, то подсвечиваем его.
В итоге мы получили короткий код подсветки, быстрый и эффективный, которому совершенно не важно, сколько всего в таблице <td>
.
Применение делегирования: действия в разметке
Есть и другие применения делегирования.
Например, нам нужно сделать меню с разными кнопками: «Сохранить (save)», «Загрузить (load)», «Поиск (search)» и т.д. И есть объект с соответствующими методами save
, load
, search
… Как их состыковать?
Первое, что может прийти в голову – это найти каждую кнопку и назначить ей свой обработчик среди методов объекта. Но существует более элегантное решение. Мы можем добавить один обработчик для всего меню и атрибуты data-action
для каждой кнопки в соответствии с методами, которые они вызывают:
<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>
class Menu {
constructor(elem) {
elem.onclick = this.onClick.bind(this); // (*)
}
save() {
alert('сохраняю');
}
load() {
alert('загружаю');
}
search() {
alert('ищу');
}
onClick(event) {
let action = event.target.dataset.action;
if (action) {
this[action]();
}
}
}
new Menu(menu);
</script>
Обратите внимание, что метод this.onClick
в строке, отмеченной звёздочкой (*)
, привязывается к контексту текущего объекта this
. Это важно, т.к. иначе this
внутри него будет ссылаться на DOM-элемент (elem
), а не на объект Menu
, и this[action]
будет не тем, что нам нужно.
Так что же даёт нам здесь делегирование?
- Не нужно писать код, чтобы присвоить обработчик каждой кнопке. Достаточно просто создать один метод и поместить его в разметку.
- Структура HTML становится по-настоящему гибкой. Мы можем добавлять/удалять кнопки в любое время.
Мы также можем использовать классы .action-save
, .action-load
, но подход с использованием атрибутов data-action
является более семантичным. Их можно использовать и для стилизации в правилах CSS.
Приём проектирования «поведение»
Делегирование событий можно использовать для добавления элементам «поведения» (behavior), декларативно задавая хитрые обработчики установкой специальных HTML-атрибутов и классов.
Приём проектирования «поведение» состоит из двух частей:
- Элементу ставится пользовательский атрибут, описывающий его поведение.
- При помощи делегирования ставится обработчик на документ, который ловит все клики (или другие события) и, если элемент имеет нужный атрибут, производит соответствующее действие.
Поведение: «Счётчик»
Например, здесь HTML-атрибут data-counter
добавляет кнопкам поведение: «увеличить значение при клике»:
Счётчик: <input type="button" value="1" data-counter>
Ещё счётчик: <input type="button" value="2" data-counter>
<script>
document.addEventListener('click', function(event) {
if (event.target.dataset.counter != undefined) { // если есть атрибут...
event.target.value++;
}
});
</script>
Если нажать на кнопку – значение увеличится. Конечно, нам важны не счётчики, а общий подход, который здесь продемонстрирован.
Элементов с атрибутом data-counter
может быть сколько угодно. Новые могут добавляться в HTML-код в любой момент. При помощи делегирования мы фактически добавили новый «псевдостандартный» атрибут в HTML, который добавляет элементу новую возможность («поведение»).
addEventListener
для обработчиков на уровне документаКогда мы устанавливаем обработчик событий на объект document
, мы всегда должны использовать метод addEventListener
, а не document.on<событие>
, т.к. в случае последнего могут возникать конфликты: новые обработчики будут перезаписывать уже существующие.
Для реального проекта совершенно нормально иметь много обработчиков на элементе document
, установленных из разных частей кода.
Поведение: «Переключатель» (Toggler)
Ещё один пример поведения. Сделаем так, что при клике на элемент с атрибутом data-toggle-id
будет скрываться/показываться элемент с заданным id
:
<button data-toggle-id="subscribe-mail">
Показать форму подписки
</button>
<form id="subscribe-mail" hidden>
Ваша почта: <input type="email">
</form>
<script>
document.addEventListener('click', function(event) {
let id = event.target.dataset.toggleId;
if (!id) return;
let elem = document.getElementById(id);
elem.hidden = !elem.hidden;
});
</script>
Ещё раз подчеркнём, что мы сделали. Теперь для того, чтобы добавить скрытие-раскрытие любому элементу, даже не надо знать JavaScript, можно просто написать атрибут data-toggle-id
.
Это бывает очень удобно – не нужно писать JavaScript-код для каждого элемента, который должен так себя вести. Просто используем поведение. Обработчики на уровне документа сделают это возможным для элемента в любом месте страницы.
Мы можем комбинировать несколько вариантов поведения на одном элементе.
Шаблон «поведение» может служить альтернативой для фрагментов JS-кода в вёрстке.
Итого
Делегирование событий – это здорово! Пожалуй, это один из самых полезных приёмов для работы с DOM.
Он часто используется, если есть много элементов, обработка которых очень схожа, но не только для этого.
Алгоритм:
- Вешаем обработчик на контейнер.
- В обработчике проверяем исходный элемент
event.target
. - Если событие произошло внутри нужного нам элемента, то обрабатываем его.
Зачем использовать:
- Упрощает процесс инициализации и экономит память: не нужно вешать много обработчиков.
- Меньше кода: при добавлении и удалении элементов не нужно ставить или снимать обработчики.
- Удобство изменений DOM: можно массово добавлять или удалять элементы путём изменения
innerHTML
и ему подобных.
Конечно, у делегирования событий есть свои ограничения:
- Во-первых, событие должно всплывать. Некоторые события этого не делают. Также, низкоуровневые обработчики не должны вызывать
event.stopPropagation()
. - Во-вторых, делегирование создаёт дополнительную нагрузку на браузер, ведь обработчик запускается, когда событие происходит в любом месте контейнера, не обязательно на элементах, которые нам интересны. Но обычно эта нагрузка настолько пустяковая, что её даже не стоит принимать во внимание.