Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Делегування подій». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Делегування подій (event delegation)** - патерн, де один обробник на батьківському елементі перехоплює події від усіх дочірніх через спливання. ```javascript document.querySelector('ul').addEventListener('click', (e) => { if (e.target.matches('li')) console.log(e.target.textContent); }); ``` **Ключове:** працює для динамічно доданих елементів без перепідписки.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Делегування подій (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`. Жодних нових обробників, жодних зайвих підписок.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.