Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Пропагування подій у JavaScript та його фази». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Пропагування подій (event propagation)** - це процес доставки DOM-події через дерево елементів у 3 фази: захоплення (від кореня до цілі), ціль і спливання (від цілі до кореня). ```js el.addEventListener('click', handler); // спливання (за замовчуванням) el.addEventListener('click', handler, true); // захоплення el.addEventListener('click', e => e.stopPropagation()); // зупинити рух ``` **Ключове:** За замовчуванням - спливання. `true` вмикає захоплення. `event.eventPhase` показує поточну фазу: 1 - захоплення, 2 - ціль, 3 - спливання.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Пропагування подій (event propagation)** - це шлях, який браузер прокладає для доставки DOM-події через дерево елементів: зверху вниз під час захоплення, потім на цільовому елементі, потім назад угору під час спливання. ## Теорія ### TL;DR - Клік на кнопці запускає 3 фази: захоплення (від кореня до цілі), ціль, спливання (від цілі до кореня) - `addEventListener` за замовчуванням працює у фазі спливання - Третій аргумент `true` реєструє обробник на фазу захоплення - `stopPropagation()` зупиняє рух у поточному напрямку, але обробники іншої фази можуть вже відпрацювати - Спливання використовуй для делегування подій; захоплення - для раннього перехоплення на контейнері ### Швидкий приклад ```js ['grandparent', 'parent', 'child'].forEach(id => { const el = document.getElementById(id); // Фаза захоплення: спрацьовує зверху вниз el.addEventListener('click', () => console.log(id + ' capture'), true); // Фаза спливання: спрацьовує знизу вгору (за замовчуванням) el.addEventListener('click', () => console.log(id + ' bubble')); }); // Клік на #child виведе: // grandparent capture -> parent capture -> child capture // child bubble -> parent bubble -> grandparent bubble ``` Обробники захоплення йдуть зверху вниз, а спливання знизу вгору. Ціль (`#child`) отримує обидва напрямки в одному проході. ### Три фази Кожен клік, натиснення клавіші чи фокус проходять один і той самий маршрут. Браузер спочатку будує шлях поширення від `window` до цільового елемента через `composedPath()`, а потім проходить цей масив у три кроки. Захоплення йде зверху вниз. Подія відвідує кожного предка цільового елемента до того, як дійде до нього, даючи кожному шанс перехопити її заздалегідь. Більшість розробників цю фазу ігнорує. Вона все одно відбувається. Фаза цілі спрацьовує на елементі, по якому реально клікнули. Тут виконуються обидва типи обробників, зареєстрованих безпосередньо на цьому елементі, у порядку реєстрації. Спливання йде знизу вгору по тому самому шляху. Більшість обробників живе саме тут, бо це поведінка за замовчуванням. ### Головна різниця Захоплення дає можливість перехопити подію до того, як вона досягне цілі. Модальне вікно може зловити кліки під час захоплення і не пустити їх до дочірніх кнопок. Спливання - це те, що робить делегування подій можливим: один обробник на батьківському елементі ловить кліки всіх нащадків, бо вони піднімаються вгору через дерево. ### Коли що використовувати - Блокування кліків до дочірніх елементів: захоплення на предку (модальні вікна, перевірка дозволів) - Обробка динамічних списків без окремих обробників на кожен елемент: спливання на батьку з `e.target.closest()` - Реакція тільки на точний клікнутий елемент: `e.target === e.currentTarget` - Зупинка поширення в конкретному вузлі: `e.stopPropagation()` всередині обробника ### Як браузер будує і проходить шлях поширення Chrome будує шлях через `composedPath()`, який повертає масив від цілі до `window`. При відправці браузер перевертає масив для фази захоплення, спрацьовує на цілі, потім проходить вперед для спливання. `event.eventPhase` показує поточну фазу: `1` - захоплення, `2` - ціль, `3` - спливання. Shadow DOM ускладнює картину. події, що перетинають межу тіньового дерева, перенацілюються: всередині shadow root `e.target` показує справжній елемент, а ззовні хоста стає самим хостом. Обробники захоплення ззовні не можуть проникнути в shadow root. ### Типові помилки **Припускати, що `stopPropagation()` зупиняє подію повністю** ```js child.addEventListener('click', e => { e.stopPropagation(); // зупиняє спливання console.log('child handler'); }); // Обробники захоплення на предках вже спрацювали до цього ``` `stopPropagation()` зупиняє рух тільки в поточному напрямку. Якщо на батьківському елементі був обробник захоплення, він вже відпрацював до того, як дочірній отримав подію. **Очікувати захоплення без `true`** ```js // Спливання - спрацює після дочірнього обробника parent.addEventListener('click', handler); // Захоплення - спрацює до дочірнього обробника parent.addEventListener('click', handler, true); ``` За замовчуванням завжди спливання. Якщо потрібно, щоб батько спрацював раніше за дочірній елемент, потрібен прапорець `true`. **Довіряти `e.target` у делегованому обробнику без уточнення** ```js ul.addEventListener('click', e => { // e.target може бути <button> всередині <li>, а не сам <li> console.log(e.target.textContent); }); // Виправлення: піднятись до потрібного елемента ul.addEventListener('click', e => { const item = e.target.closest('li'); if (!item) return; console.log(item.textContent); }); ``` `e.target` - це найглибший елемент, який отримав клік. Використовуй `closest()`, щоб дістатись до контейнера, який тебе цікавить. **Двічі реєструвати той самий обробник** ```js el.addEventListener('click', handler); el.addEventListener('click', handler); // обидва запускаються незалежно // Виправлення: {once: true} або removeEventListener перед повторним додаванням el.addEventListener('click', handler, { once: true }); ``` Два виклики `addEventListener` реєструють два окремих обробника. `{once: true}` знімає обробник автоматично після першого спрацювання. ### Де зустрічається в реальних проєктах - React 18: `onClick` делегує всі події до кореневого контейнера, а не до конкретного елемента. Саме тому `e.target` залишається коректним навіть в async-колбеках - Vue 3: `@click.capture` вмикає фазу захоплення; `@click.stop` викликає `stopPropagation()` автоматично - jQuery / Bootstrap: `.on('click', selector, handler)` покладається на спливання для делегування на динамічних елементах - Будь-який динамічний список: один обробник на контейнер, фільтрація через `e.target.matches('li')` або `e.target.closest('li')` ### Follow-up питання **Q:** Що повертає `event.eventPhase` і коли це корисно? **A:** `1` під час захоплення, `2` коли подія на цільовому елементі, `3` під час спливання. Читай всередині будь-якого обробника, щоб перевірити, яка фаза зараз активна. Особливо корисно, якщо одна функція обробляє обидві фази. **Q:** Чим `stopPropagation()` відрізняється від `stopImmediatePropagation()`? **A:** `stopPropagation()` забороняє переміщення до наступного елемента в шляху. `stopImmediatePropagation()` робить те саме плюс скасовує решту обробників, зареєстрованих на поточному елементі в тій самій фазі. **Q:** Чи працює пропагування подій через iframe? **A:** Ні. `iframe` - це окремий контекст браузера з власним `window`. події не перетинають цей кордон, і це зроблено навмисно з міркувань безпеки. **Q:** Що таке `composedPath()` і коли він потрібен? **A:** Повертає повний шлях поширення, включно з елементами всередині Shadow DOM. Потрібен у веб-компонентах, коли треба знати реальне джерело кліку до того, як відбулося перенацілювання на межі тіньового дерева. **Q:** У Shadow DOM з `slot`-ами: що відбувається з `e.target` під час захоплення від хоста? **A:** Захоплення починається від кореня документа. На межі тіньового дерева браузер перенацілює `e.target` на хост для всіх зовнішніх обробників. Всередині shadow root `e.target` досі показує реально клікнутий елемент. `e.composedPath()` розкриває повний оригінальний шлях, включно з усіма shadow-слотами. ## Приклади ### Базовий: відстеження трьох рівнів поширення ```html <div id="grandparent"> <div id="parent"> <button id="child">Натисни мене</button> </div> </div> <script> ['grandparent', 'parent', 'child'].forEach(id => { const el = document.getElementById(id); el.addEventListener('click', () => console.log(id + ' capture'), true); el.addEventListener('click', () => console.log(id + ' bubble')); }); // Виведе при кліку на кнопку: // grandparent capture // parent capture // child capture // child bubble // parent bubble // grandparent bubble </script> ``` Консоль показує повний маршрут події. Захоплення зверху вниз, спливання знизу вгору. Цільовий елемент (`#child`) бере участь в обох напрямках в межах одного циклу відправки. ### Середній рівень: делегування подій на динамічному списку (React 18) ```jsx function TodoList({ todos }) { const handleClick = (e) => { const btn = e.target.closest('[data-delete]'); if (!btn) return; console.log('Видалити todo id:', btn.dataset.id); // 'Видалити todo id: 42' }; return ( <ul onClick={handleClick}> {todos.map(todo => ( <li key={todo.id}> {todo.text} <button data-delete data-id={todo.id}>Видалити</button> </li> ))} </ul> ); } ``` Один обробник на `<ul>` обслуговує видалення для всіх елементів. При додаванні або видаленні todo нічого оновлювати не треба. Спливання несе кожен клік угору до батька автоматично. ### Просунутий рівень: `stopPropagation()` не скасовує захоплення ```js const parent = document.getElementById('parent'); const child = document.getElementById('child'); // Захоплення на батьку спрацьовує першим, до того як дочірній щось отримав parent.addEventListener('click', () => console.log('parent capture'), true); // Дочірній зупиняє спливання після обробки child.addEventListener('click', e => { e.stopPropagation(); console.log('child handler'); }); // Це не спрацює - спливання зупинено parent.addEventListener('click', () => console.log('parent bubble')); // Клік на child виведе: // parent capture <-- захоплення вже відпрацювало до stopPropagation // child handler // (parent bubble пропущено) ``` Зупинка спливання не скасовує вже виконане захоплення. Обробник захоплення на `parent` відпрацював ще до того, як дочірній елемент отримав подію. Це класична пастка, в яку потрапляють досвідчені розробники, які вважали `stopPropagation()` повним вимикачем.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.