Спливання та перехоплення дозволяють нам реалізувати один з найпотужніших шаблонів обробки подій під назвою делегування подій.
Ідея в тому, що якщо у нас є багато елементів, які обробляються подібним чином, то замість того, щоб призначати обробник кожному з них, ми ставимо один обробник на їхнього спільного предка.
У обробнику ми отримуємо event.target
, щоб побачити, де насправді сталася подія і обробити її.
Давайте подивимося на приклад – діаграму Ба-Гуа, що відображає стародавню китайську філософію.
Ось вона:
HTML виглядає так:
<table>
<tr>
<th colspan="3">Таблиця <em>Багуа</em>: Напрямок, Елемент, Колір, Значення</th>
</tr>
<tr>
<td class="nw"><strong>Північний захід</strong><br>Метал<br>Срібний<br>Старійшини</td>
<td class="n">...</td>
<td class="ne">...</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>
, тоді виконання функції одразу завершиться, оскільки більше робити нічого. - У разі вкладених таблиць
event.target
може бути<td>
, але знаходитись за межами поточної таблиці. Тож ми перевіряємо, чи це насправді<td>
нашої таблиці. - І якщо це так, то виділяємо його.
В результаті ми маємо швидкий, ефективний код для виділення, який не залежить від загальної кількості <td>
у таблиці.
Приклад делегування: дії в розмітці
Є й інші варіанти використання делегування подій.
Скажімо, ми хочемо створити меню з кнопками «Зберегти», «Завантажити», «Пошук» і так далі. А ще є об’єкт з методами 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) {
this._elem = 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.
Шаблон “поведінки”
Делегування подій можна також використовувати для додавання певної «поведінки» елементам декларативно, за допомогою спеціальних атрибутів та класів.
Шаблон складається з двох частин:
- Ми додаємо спеціальний атрибут до елемента, який описує його поведінку.
- За допомогою делегування ставиться один обробник на документ, що відстежує усі події і, якщо елемент має атрибут, виконує відповідну дію.
Поведінка: Лічильник
Наприклад, тут атрибут 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<event>
, оскільки останній спричинить конфлікти: нові обробники перезапишуть старі.
Для реальних проєктів вважається нормальною наявність великої кількості обробників на document
, встановлених різними частинами коду.
Поведінка: Перемикач
Ще один приклад поведінки. Клікніть на елементі з атрибутом 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 для кожного такого елемента. Просто використовуйте поведінку. Обробник на рівні документу дозволяє працювати з будь-яким елементом сторінки.
Ми також можемо об’єднати кілька видів поведінки в одному елементі.
Шаблон «поведінка» може бути альтернативою мініфрагментам JavaScript.
Підсумки
Делегування подій – це дійсно круто! Це один з найбільш корисних шаблонів для подій DOM.
Він часто використовується для додавання однакової обробки для багатьох подібних елементів, але не тільки для цього.
Алгоритм:
- Додайте один обробник на контейнер.
- У обробнику – перевірте вихідний елемент
event.target
. - Якщо подія відбулася всередині елемента, який нас цікавить, обробіть подію.
Переваги:
- Спрощує ініціалізацію та економить пам’ять: не потрібно додавати багато обробників.
- Менше коду: під час додавання або видалення елементів не потрібно додавати/видаляти обробники.
- Модифікації DOM: ми можемо масово додавати/видаляти елементи за допомогою
innerHTML
тощо.
Звичайно, делегування має свої обмеження:
- По-перше, подія повинна спливати. Деякі події не спливають. Крім того, низькорівневі обробники не повинні використовувати
event.stopPropagation()
. - По-друге, делегування може збільшити навантаження на центральний процесор, оскільки обробник на рівні контейнера реагує на події в будь-якому місці контейнера, незалежно від того, цікавлять вони нас чи ні. Але зазвичай навантаження незначне, тому ми не беремо його до уваги.
Коментарі
<code>
, для кількох рядків – обгорніть їх тегом<pre>
, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)