Розгляньте цей приклад та спробуйте передбачити, що станеться:
<div id="parent" onclick="alert('Клік на DIV!')">
<em>Якщо клікнути на <code>цей код</code>, спрацює обробник на DIV</em>
</div>
Питання: Ви клікаєте на елемент <code>, але чому спрацьовує обробник на <div>? 🤔
Відповідь: Це не баг, а одна з найпотужніших особливостей DOM — механізм спливання подій (event bubbling)!
Уявіть, що ви створюєте таблицю з 1000 рядків, і потрібно обробляти клік на кожній комірці:
❌ Підхід без розуміння спливання:
// Призначаємо 1000+ обробників — неефективно!
const cells = document.querySelectorAll('td')
cells.forEach((cell) => {
cell.addEventListener('click', function () {
console.log('Клік на комірці:', this.textContent)
})
})
✅ Розумний підхід з використанням спливання:
// Один обробник замість тисячі!
document.querySelector('table').addEventListener('click', function (event) {
if (event.target.tagName === 'TD') {
console.log('Клік на комірці:', event.target.textContent)
}
})
Переваги:
Цей підхід називається делегування подій — ми детально розглянемо його в наступній статті.
Згідно зі специфікацією W3C DOM Events, кожна подія проходить три фази:
Подія спускається від кореня документа до цільового елемента.
Маршрут: document → html → body → ... → target
Подія досягає цільового елемента (event.target).
Спрацьовують обробники, призначені безпосередньо на цей елемент.
Подія піднімається від цільового елемента назад до кореня.
Маршрут: target → ... → body → html → document
addEventListener(), властивостей onclick, та HTML-атрібутів.Давайте розглянемо вкладену структуру HTML:
<style>
.box {
padding: 20px;
margin: 10px;
border: 2px solid;
cursor: pointer;
}
.form {
background: #dbeafe;
border-color: #3b82f6;
}
.div {
background: #fef3c7;
border-color: #f59e0b;
}
.p {
background: #fecaca;
border-color: #ef4444;
}
</style>
<form class="box form" onclick="alert('FORM')">
FORM
<div class="box div" onclick="alert('DIV')">
DIV
<p class="box p" onclick="alert('P')">P</p>
</div>
</form>
Структура вкладеності:
FORM (синій)
└─ DIV (жовтий)
└─ P (червоний)
Що станеться при кліку на <p>?
<p> → alert("P")<div> → alert("DIV")<form> → alert("FORM")Результат: Побачите три alert в порядку: P → DIV → FORM
<p>):Одна з найбільш заплутаних концепцій для початківців. Розберемо детально:
// Завжди показує елемент, на який ви клікнули
console.log(event.target)
// Показує елемент, чий обробник виконується зараз
console.log(event.currentTarget)
thisдорівнює event.currentTarget.elem.addEventListener('click', function (event) {
console.log(this === event.currentTarget) // true
})
this НЕ вказує на елемент!<div id="outer" style="padding: 50px; background: #dbeafe; border: 3px solid #3b82f6;">
OUTER DIV
<div id="middle" style="padding: 30px; background: #fef3c7; border: 3px solid #f59e0b;">
MIDDLE DIV
<button id="inner">INNER BUTTON</button>
</div>
</div>
<script>
const outer = document.getElementById('outer')
outer.addEventListener('click', function (event) {
console.log('━━━━━━━━━━━━━━━━━━━━━━')
console.log('Обробник виконується на:', this.id)
console.log('event.target (хто викликав):', event.target.id)
console.log('event.currentTarget (де обробник):', event.currentTarget.id)
console.log('this === event.currentTarget:', this === event.currentTarget)
console.log('━━━━━━━━━━━━━━━━━━━━━━')
})
</script>
Що побачимо при кліку на кнопку:
━━━━━━━━━━━━━━━━━━━━━━
Обробник виконується на: outer
event.target (хто викликав): inner
event.currentTarget (де обробник): outer
this === event.currentTarget: true
━━━━━━━━━━━━━━━━━━━━━━
Створимо обробник, який показує різницю між target та currentTarget:
<!DOCTYPE html>
<html>
<head>
<style>
.container {
padding: 40px;
background: #f1f5f9;
border: 3px solid #64748b;
font-family: Arial, sans-serif;
}
.box {
padding: 30px;
margin: 20px;
background: #e0e7ff;
border: 3px solid #6366f1;
}
.highlight-target {
background: #fef08a !important;
border-color: #eab308 !important;
}
.info {
position: fixed;
top: 20px;
right: 20px;
padding: 20px;
background: white;
border: 2px solid #3b82f6;
border-radius: 8px;
max-width: 300px;
font-size: 14px;
}
</style>
</head>
<body>
<div class="info" id="info">
<strong>Інформація про клік:</strong>
<div id="infoContent">Клікніть на будь-який елемент</div>
</div>
<div class="container" id="container">
<h2>КОНТЕЙНЕР (container)</h2>
<div class="box" id="box1">
<h3>BOX 1 (box1)</h3>
<p>Клікніть на <strong>цей текст</strong> або на будь-який елемент</p>
</div>
<div class="box" id="box2">
<h3>BOX 2 (box2)</h3>
<button id="btn">Кнопка</button>
</div>
</div>
<script>
const container = document.getElementById('container')
const infoContent = document.getElementById('infoContent')
// Додаємо обробник на контейнер
container.addEventListener('click', function (event) {
// Підсвічуємо цільовий елемент
document.querySelectorAll('.highlight-target').forEach((el) => {
el.classList.remove('highlight-target')
})
event.target.classList.add('highlight-target')
// Показуємо інформацію
infoContent.innerHTML = `
<div style="margin-top: 10px;">
<strong style="color: #f59e0b;">event.target:</strong><br/>
<${event.target.tagName.toLowerCase()}>
${event.target.id ? '#' + event.target.id : ''}
${event.target.className ? '.' + event.target.className : ''}
<br/><br/>
<strong style="color: #3b82f6;">event.currentTarget:</strong><br/>
<${event.currentTarget.tagName.toLowerCase()}>
#${event.currentTarget.id}
<br/><br/>
<strong style="color: #10b981;">this:</strong><br/>
<${this.tagName.toLowerCase()}> #${this.id}
<br/><br/>
<em>Подія спливла від ${event.target.tagName} до ${event.currentTarget.tagName}</em>
</div>
`
// Знімаємо підсвічування через 2 секунди
setTimeout(() => {
event.target.classList.remove('highlight-target')
}, 2000)
})
</script>
</body>
</html>
Що відбувається:
div.container, де спрацьовує обробникevent.target — елемент, на який ви клікнулиevent.currentTarget та this — завжди div.container| Подія | Чому не спливає |
|---|---|
focus / blur | Фокус — це стан конкретного елемента, не має сенсу спливати |
mouseenter / mouseleave | Спеціально розроблені як несплываючі альтернативи mouseover/mouseout |
load / unload | Застосовуються лише до конкретного ресурсу (зображення, сторінка) |
scroll | Прокрутка відбувається в конкретному контейнері |
resize | Зміна розміру стосується конкретного елемента/вікна |
| Media події | play, pause, ended — стосуються конкретного медіа-елемента |
// Всі події мають властивість bubbles
document.addEventListener(
'focus',
(e) => {
console.log(e.bubbles) // false
},
true,
)
document.addEventListener('click', (e) => {
console.log(e.bubbles) // true
})
// Замість focus/blur використовуйте focusin/focusout
element.addEventListener('focusin', handler) // Спливає! ✅
element.addEventListener('focusout', handler) // Спливає! ✅
Іноді потрібно зупинити подію від подальшого спливання. Для цього використовується метод event.stopPropagation():
<div id="outer" onclick="alert('Не побачите цього')">
ЗОВНІШНІЙ DIV
<button id="inner" onclick="event.stopPropagation()">Натисни мене — подія зупиниться тут</button>
</div>
Що станеться:
<div><div> не виконаєтьсяЯкщо на елементі кілька обробників, є важлива різниця:
const button = document.getElementById('btn')
// Обробник 1
button.addEventListener('click', (e) => {
console.log('Обробник 1')
e.stopPropagation() // Зупиняє спливання, але НЕ зупиняє інші обробники
})
// Обробник 2
button.addEventListener('click', (e) => {
console.log('Обробник 2') // ВСЕ ОДНО виконається!
})
// При кліку побачимо:
// Обробник 1
// Обробник 2
Для повної зупинки використовуйте stopImmediatePropagation():
button.addEventListener('click', (e) => {
console.log('Обробник 1')
e.stopImmediatePropagation() // Зупиняє ВСЕ: і спливання, і інші обробники
})
button.addEventListener('click', (e) => {
console.log('Обробник 2') // НЕ виконається!
})
// При кліку побачимо лише:
// Обробник 1
| Метод | Спливання вгору | Інші обробники на елементі |
|---|---|---|
| (нічого не викликали) | ✅ Продовжується | ✅ Виконуються |
stopPropagation() | ❌ Зупиняється | ✅ Виконуються |
stopImmediatePropagation() | ❌ Зупиняється | ❌ Не виконуються |
stopPropagation()menu.addEventListener('click', (e) => {
handleMenuClick(e)
e.stopPropagation() // Зупиняємо спливання
})
document.addEventListener('click', (e) => {
sendAnalytics(e.target) // Не спрацює для меню! 😢
})
// Проблемний підхід
button.addEventListener('click', (e) => {
e.stopPropagation() // Блокує спливання назавжди
handleButtonClick()
})
document.addEventListener('click', () => {
// Ніколи не виконається для цієї кнопки!
trackClick()
})
// Кнопка "повідомляє" про обробку через defaultPrevented
button.addEventListener('click', (e) => {
e.preventDefault() // Позначаємо подію як оброблену
handleButtonClick()
})
document.addEventListener('click', (e) => {
// Перевіряємо, чи вже оброблена подія
if (e.defaultPrevented) return
trackClick() // Виконається для інших елементів
})
// Використовуємо спеціальний маркер
button.addEventListener('click', (e) => {
e.target.dataset.handled = 'true'
handleButtonClick()
})
document.addEventListener('click', (e) => {
if (e.target.dataset.handled) return
trackClick() // Виконається для необроблених елементів
})
За замовчуванням обробники працюють на фазі спливання. Але можна перехопити подію до того, як вона досягне цільового елемента — на фазі занурення.
// Третій параметр true або { capture: true }
element.addEventListener('click', handler, true)
// Або з об'єктом опцій
element.addEventListener('click', handler, { capture: true })
document.addEventListener('click', () => {
console.log('document')
})
body.addEventListener('click', () => {
console.log('body')
})
div.addEventListener('click', () => {
console.log('div — ЦІЛЬ')
})
// Клік на div виведе:
// div — ЦІЛЬ
// body
// document
document.addEventListener(
'click',
() => {
console.log('document')
},
true,
) // capture: true
body.addEventListener(
'click',
() => {
console.log('body')
},
true,
)
div.addEventListener('click', () => {
console.log('div — ЦІЛЬ')
})
// Клік на div виведе:
// document
// body
// div — ЦІЛЬ
document.addEventListener(
'click',
() => {
console.log('document — capturing')
},
true,
)
document.addEventListener('click', () => {
console.log('document — bubbling')
})
div.addEventListener('click', () => {
console.log('div — ЦІЛЬ')
})
// Клік на div виведе:
// document — capturing (⬇️ занурення)
// div — ЦІЛЬ (🎯 ціль)
// document — bubbling (⬆️ спливання)
<div id="outer">
<div id="middle">
<div id="inner">КЛІКНИ СЮДИ</div>
</div>
</div>
<script>
const outer = document.getElementById('outer')
const middle = document.getElementById('middle')
const inner = document.getElementById('inner')
// Capturing обробники
outer.addEventListener('click', () => console.log('outer — capturing'), true)
middle.addEventListener('click', () => console.log('middle — capturing'), true)
inner.addEventListener('click', () => console.log('inner — capturing'), true)
// Bubbling обробники
outer.addEventListener('click', () => console.log('outer — bubbling'))
middle.addEventListener('click', () => console.log('middle — bubbling'))
inner.addEventListener('click', () => console.log('inner — bubbling'))
</script>
Результат при кліку на inner:
outer — capturing ⬇️ Фаза 1: Занурення (document → inner)
middle — capturing ⬇️
inner — capturing ⬇️
inner — bubbling ⬆️ Фаза 3: Спливання (inner → document)
middle — bubbling ⬆️
outer — bubbling ⬆️
// Перехоплення ВСІХ кліків до їх обробки
document.addEventListener(
'click',
(e) => {
console.log('Глобальний аудит кліка:', e.target)
// Логування, валідація, перевірка дозволів
},
true,
)
const restrictedArea = document.getElementById('restrictedArea')
restrictedArea.addEventListener(
'click',
(e) => {
if (!userHasPermission()) {
e.stopPropagation() // Зупиняємо на фазі занурення!
alert('Доступ заборонено')
}
},
true,
)
// Батьківський елемент обробляє подію ПЕРШИМ
parent.addEventListener(
'keydown',
(e) => {
if (e.key === 'Escape') {
closeModal()
e.stopPropagation() // Дочірні не отримають Escape
}
},
true,
)
Кожна подія має властивість eventPhase, яка показує номер поточної фази:
element.addEventListener(
'click',
(e) => {
console.log(e.eventPhase)
// 1 = Event.CAPTURING_PHASE
// 2 = Event.AT_TARGET
// 3 = Event.BUBBLING_PHASE
},
true,
)
Константи:
Event.CAPTURING_PHASE = 1 — фаза зануренняEvent.AT_TARGET = 2 — подія на цільовому елементіEvent.BUBBLING_PHASE = 3 — фаза спливанняПрактичне використання:
function universalHandler(e) {
const phases = ['none', 'capturing', 'target', 'bubbling']
console.log(`Фаза: ${phases[e.eventPhase]}, Елемент: ${e.currentTarget.id}`)
}
outer.addEventListener('click', universalHandler, true) // capturing
middle.addEventListener('click', universalHandler) // bubbling
inner.addEventListener('click', universalHandler) // bubbling
⚠️ Видалення обробників
Для видалення capture-обробника потрібно вказати ту саму фазу:
const handler = () => console.log('click')
// Додали з capturing
elem.addEventListener('click', handler, true)
// ❌ Не видалить — різні фази!
elem.removeEventListener('click', handler)
// ✅ Видалить — та сама фаза
elem.removeEventListener('click', handler, true)
📋 Порядок обробників
Обробники на одній фазі виконуються в порядку реєстрації:
elem.addEventListener('click', () => console.log('1'))
elem.addEventListener('click', () => console.log('2'))
elem.addEventListener('click', () => console.log('3'))
// Завжди виведе: 1, 2, 3
🎯 Фаза цілі
На цільовому елементі обидві фази виконуються:
target.addEventListener('click', () => console.log('capturing'), true)
target.addEventListener('click', () => console.log('bubbling'))
// При кліку на target виведе:
// capturing
// bubbling
🚫 stopPropagation на capturing
stopPropagation() на фазі занурення зупиняє ВСЕ:
parent.addEventListener(
'click',
(e) => {
e.stopPropagation() // Зупинка на capturing
},
true,
)
child.addEventListener('click', () => {
console.log('Не виконається!') // Подія не дійшла
})
Створимо систему, де адміністратор може блокувати інтерфейс для звичайних користувачів:
<!DOCTYPE html>
<html>
<head>
<style>
.app {
padding: 20px;
font-family: Arial;
}
.admin-panel {
position: fixed;
top: 10px;
right: 10px;
padding: 15px;
background: #fee;
border: 2px solid #f00;
border-radius: 8px;
}
.content {
padding: 20px;
background: #f0f0f0;
border-radius: 8px;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
}
.locked::after {
content: '🔒';
margin-left: 5px;
}
</style>
</head>
<body>
<div class="app">
<div class="admin-panel">
<h3>Адмін-панель</h3>
<label>
<input type="checkbox" id="lockToggle" checked />
Заблокувати інтерфейс
</label>
</div>
<div class="content" id="content">
<h1>Контент програми</h1>
<button id="btn1">Кнопка 1</button>
<button id="btn2">Кнопка 2</button>
<input type="text" placeholder="Введіть текст" />
<p>Клікніть на будь-який елемент</p>
</div>
</div>
<script>
const content = document.getElementById('content')
const lockToggle = document.getElementById('lockToggle')
let isLocked = true
lockToggle.addEventListener('change', (e) => {
isLocked = e.target.checked
content.classList.toggle('locked', isLocked)
})
// Використовуємо CAPTURING для глобальної блокування
content.addEventListener(
'click',
(e) => {
if (isLocked) {
e.stopPropagation()
e.preventDefault()
alert('⛔ Інтерфейс заблоковано адміністратором!')
}
},
true,
) // ВАЖЛИВО: capturing = true
// Звичайні обробники на кнопках (не спрацюють при блокуванні)
document.getElementById('btn1').addEventListener('click', () => {
alert('Обробник кнопки 1')
})
document.getElementById('btn2').addEventListener('click', () => {
alert('Обробник кнопки 2')
})
</script>
</body>
</html>
Як це працює:
isLocked === true, зупиняє подію через stopPropagation()Коли подія відбувається, вона спливає від цільового елемента до кореня документа, викликаючи обробники на шляху.
Порядок: target → parent → grandparent → ... → document
event.target — де подія насправді відбулася (завжди незмінний)event.currentTarget (= this) — де виконується обробник (змінюється при спливанні)addEventListener(event, handler) — обробник на фазі спливанняaddEventListener(event, handler, true) — обробник на фазі занурення
stopPropagation() — зупиняє спливання, інші обробники на елементі виконаютьсяstopImmediatePropagation() — повна зупинка (спливання + інші обробники)⚠️ Краще уникайте — може створити "мертві зони" для майбутньої логіки
Використовуйте, коли потрібна пріоритетна обробка (валідація, логування, контроль доступу)
event.target для визначення, на що клікнули<div>!data-access-level="admin")document з { capture: true } може контролювати весь додаток!preventDefault()document.addEventListener БЕЗ stopPropagation в інших обробниках!📚 Специфікація W3C
DOM Events Level 3 — офіційна специфікація механізму подій
Event interface — WHATWG Living Standard
🎓 MDN Web Docs
🔗 Пов'язані статті
Наступна стаття: Делегування подій — як один обробник може керувати тисячами елементів