Делегування подій
Делегування подій (event delegation) - це патерн, де один обробник прикріплюється до батьківського елемента, а спливання події доставляє кліки від будь-якого дочірнього елемента до цього одного обробника.
Теорія
TL;DR
- Аналогія: стійка ресепшену в готелі. Гості з будь-якого номеру підходять до одного місця, замість того щоб у кожному номері був окремий адміністратор.
- Головна різниця: один обробник на батьківському елементі проти одного обробника на кожному дочірньому. Батьківський варіант переживає зміни DOM без перепідписки.
event.target- це елемент, по якому клікнули;event.currentTarget- завжди батьківський елемент з обробником.- Використовуй, коли дочірні елементи динамічні (списки з API) або їх багато (50+).
- Для 3-5 статичних елементів, що ніколи не змінюються, прямі обробники зручніші.
Швидкий приклад
<ul id="menu">
<li data-action="home">Головна</li>
<li data-action="about">Про нас</li>
</ul>// Без делегування - ламається при зміні списку
// document.querySelectorAll('li').forEach(li => li.addEventListener('click', handler));
// З делегуванням - працює для будь-якого li, теперішнього і майбутнього
document.getElementById('menu').addEventListener('click', (e) => {
if (e.target.matches('li')) {
console.log(e.target.dataset.action); // "home" або "about"
}
});Додай новий <li data-action="contact">Контакт</li> в рантаймі - обробник спрацює без жодного перепідписування.
Ключова різниця
Прямі обробники прикріплюються до кожного дочірнього елемента в момент виклику addEventListener. Додаси новий елемент пізніше - на ньому не буде обробника. Делегування перевертає цю логіку: батьківський елемент вже слухає, а нові дочірні елементи автоматично спливають до нього. Ціна - одна перевірка на клік через matches() або closest(). Це ніколи не стає вузьким місцем.
Коли використовувати
- Динамічні списки (результати пошуку, нескінченний скрол, todo-елементи з API) → делегуй на контейнер.
- Більше 50 схожих дочірніх елементів → один обробник краще десятків.
- Мобільні або чутливі до продуктивності інтерфейси → менше обробників означає менше навантаження на пам'ять.
- 3-5 статичних елементів, що ніколи не змінюються → прямі обробники зручніші.
- Shadow DOM або кастомні елементи → перевір сумісність, події не перетинають shadow-межі за замовчуванням.
Як це працює всередині
Браузер відправляє події в три фази: захоплення (від кореня до цілі), цільова фаза, потім спливання (від цілі назад до кореня). Делегування працює у фазі спливання, яка є типовою за замовчуванням. Коли користувач клікає на <li>, подія потрапляє на цей елемент, потім піднімається через <ul>, <body>, <html>, document. Обробник на батьківському елементі перехоплює її під час цього шляху.
Браузер дає тобі event.target (оригінальний клікнутий елемент) і event.currentTarget (елемент, де живе обробник). Ця різниця і є вся суть трюку.
Я бачив, як цей патерн виправив реальний memory leak у чат-застосунку, який прикріплював нові обробники до рядків повідомлень при кожному WebSocket-оновленні. Один делегований обробник на контейнер-список вирішив проблему повністю.
Типові помилки
Забули відфільтрувати за типом елемента:
// Неправильно - спрацьовує на самому ul або будь-якому вкладеному span/img
parent.addEventListener('click', (e) => console.log(e.target.textContent));
// Правильно - перевіряй конкретний елемент
parent.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (li) console.log(li.textContent);
});closest() безпечніший за matches(), коли всередині <li> є вкладені іконки або <span>.
Плутають target і currentTarget:
parent.addEventListener('click', (e) => {
console.log(e.currentTarget); // Завжди батьківський елемент - не те, що потрібно
console.log(e.target); // Справжній клікнутий елемент - ось що потрібно
});Дочірній елемент викликає stopPropagation():
// Десь у коді:
child.addEventListener('click', (e) => e.stopPropagation()); // Вбиває спливання
// Твій батьківський обробник більше не спрацює
parent.addEventListener('click', handler); // Нічого не відбуваєтьсяЯкщо контролюєш обидва місця, уникай stopPropagation(). Якщо ні, перевіряй e.defaultPrevented як сигнал.
Делегування на document без звуження:
// Перехоплює кожен клік на сторінці - крихко
document.addEventListener('click', (e) => {
if (e.target.tagName === 'LI') doSomething();
});
// Краще - звузь до конкретного контейнера
document.getElementById('menu').addEventListener('click', (e) => {
if (e.target.matches('li')) doSomething();
});Очікування, що делегування працює з подіями без спливання:
focus, blur і scroll не спливають. Використовуй focusin/focusout як альтернативи, що спливають, або передавай {capture: true} до обробника.
Де зустрічається
- React: прикріплює один синтетичний обробник до кореневого контейнера, а не до кожного компонента окремо.
- jQuery:
$(parent).on('click', '.child', handler)має вбудоване делегування з версії 1.7. - Vue 3:
v-onна контейнері зevent.target.closest()для динамічних списків черезv-for. - Alpine.js та інші легковагові фреймворки використовують цей патерн як основу своєї архітектури.
Питання на співбесіді
Q: Яка різниця між event.target і event.currentTarget?
A: target - елемент, по якому клікнув користувач. currentTarget - елемент, де прикріплений обробник. При делегуванні currentTarget завжди батьківський; target - дочірній, з яким треба працювати.
Q: Як делегування обробляє динамічно додані елементи?
A: Автоматично. Батьківський обробник було прикріплено один раз і він залишається. Нові дочірні елементи спливають до нього без жодного перепідписування.
Q: Які події не спливають і не підходять для стандартного делегування?
A: focus, blur, scroll, mouseenter, mouseleave. Заміни на focusin/focusout (вони спливають) або використовуй {capture: true} за потреби.
Q: Що трапляється, якщо дочірній елемент викликає stopPropagation()?
A: Спливання зупиняється на цьому елементі і батьківський обробник не спрацює. Це найпоширеніша причина, чому делегування мовчки ламається у великих кодових базах.
Q: Як делегування працює в Shadow DOM? (рівень senior)
A: Події не перетинають shadow-межі за замовчуванням. Використовуй event.composedPath() для перегляду повного шляху або відправляй кастомні події з {bubbles: true, composed: true}.
Приклади
Базовий: меню з data-атрибутами
<ul id="nav">
<li data-page="home">Головна</li>
<li data-page="about">Про нас</li>
<li data-page="contact">Контакт</li>
</ul>document.getElementById('nav').addEventListener('click', (e) => {
const item = e.target.closest('li');
if (!item) return;
console.log('Перехід до:', item.dataset.page);
// Вивід: "Перехід до: home" при кліку на Головна
});closest() піднімається від клікнутого елемента вгору, поки не знайде <li>. Це працює навіть якщо користувач потрапив по <span> або іконці всередині пункту меню.
Середній: динамічний список todo
function addTodo(text) {
const li = document.createElement('li');
li.textContent = text;
li.dataset.done = 'false';
document.getElementById('todos').appendChild(li);
}
// Один обробник обслуговує всі todo, включно з тими, що додані після завантаження
document.getElementById('todos').addEventListener('click', (e) => {
const todo = e.target.closest('li');
if (!todo) return;
const isDone = todo.dataset.done === 'true';
todo.dataset.done = String(!isDone);
todo.style.textDecoration = isDone ? 'none' : 'line-through';
console.log('Перемкнуто:', todo.textContent);
});
addTodo('Купити продукти');
addTodo('Написати тести');
// Клік на будь-який елемент - обидва перемикаються без перепідпискиaddTodo продовжує додавати елементи. Обробник не змінюється. Саме в цьому і є сенс.
Просунутий: кілька дій з одного контейнера
// Кожна кнопка оголошує свою дію через data-action
const actions = {
delete: (el) => el.closest('li').remove(),
edit: (el) => {
const li = el.closest('li');
const text = prompt('Редагувати:', li.dataset.text);
if (text) li.dataset.text = text;
},
complete: (el) => el.closest('li').classList.toggle('done'),
};
document.getElementById('list').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (actions[action]) actions[action](btn);
// Один обробник направляє до потрібного хендлера за data-атрибутом
});Додати нову дію - значить додати один запис до об'єкта actions. Жодних нових обробників, жодних зайвих підписок.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.