Skip to main content

Делегування подій

Делегування подій (event delegation) - це патерн, де один обробник прикріплюється до батьківського елемента, а спливання події доставляє кліки від будь-якого дочірнього елемента до цього одного обробника.

Теорія

TL;DR

  • Аналогія: стійка ресепшену в готелі. Гості з будь-якого номеру підходять до одного місця, замість того щоб у кожному номері був окремий адміністратор.
  • Головна різниця: один обробник на батьківському елементі проти одного обробника на кожному дочірньому. Батьківський варіант переживає зміни DOM без перепідписки.
  • event.target - це елемент, по якому клікнули; event.currentTarget - завжди батьківський елемент з обробником.
  • Використовуй, коли дочірні елементи динамічні (списки з API) або їх багато (50+).
  • Для 3-5 статичних елементів, що ніколи не змінюються, прямі обробники зручніші.

Швидкий приклад

html
<ul id="menu"> <li data-action="home">Головна</li> <li data-action="about">Про нас</li> </ul>
javascript
// Без делегування - ламається при зміні списку // 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-оновленні. Один делегований обробник на контейнер-список вирішив проблему повністю.

Типові помилки

Забули відфільтрувати за типом елемента:

javascript
// Неправильно - спрацьовує на самому 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:

javascript
parent.addEventListener('click', (e) => { console.log(e.currentTarget); // Завжди батьківський елемент - не те, що потрібно console.log(e.target); // Справжній клікнутий елемент - ось що потрібно });

Дочірній елемент викликає stopPropagation():

javascript
// Десь у коді: child.addEventListener('click', (e) => e.stopPropagation()); // Вбиває спливання // Твій батьківський обробник більше не спрацює parent.addEventListener('click', handler); // Нічого не відбувається

Якщо контролюєш обидва місця, уникай stopPropagation(). Якщо ні, перевіряй e.defaultPrevented як сигнал.

Делегування на document без звуження:

javascript
// Перехоплює кожен клік на сторінці - крихко 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-атрибутами

html
<ul id="nav"> <li data-page="home">Головна</li> <li data-page="about">Про нас</li> <li data-page="contact">Контакт</li> </ul>
javascript
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

javascript
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 продовжує додавати елементи. Обробник не змінюється. Саме в цьому і є сенс.

Просунутий: кілька дій з одного контейнера

javascript
// Кожна кнопка оголошує свою дію через 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. Жодних нових обробників, жодних зайвих підписок.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?