Всплытие и делегирование #
Всплытие (bubbling) — распространение события от целевого элемента вверх по DOM к document. Делегирование — один обработчик на родителе вместо сотен на строках списка. Для динамического плейлиста и таблицы заказов JSFlow это основной паттерн производительности и простоты.
Три фазы #
- Capture — от
windowк цели (еслиcapture: true). - Target — на элементе, где произошло действие.
- Bubble — от цели вверх к
document.
document.querySelector('.dashboard').addEventListener(
'click',
() => console.log('capture: dashboard'),
true
)
document.querySelector('.widget').addEventListener('click', () => {
console.log('bubble: widget')
})
Клик по кнопке внутри виджета: сначала capture на предках, затем target, затем bubble. Большинство UI-сценариев слушают фазу bubble (значение по умолчанию).
Как работает всплытие #
<ul class="playlist">
<li class="track-row" data-track-id="tr-1">
<button type="button" data-action="play">▶</button>
</li>
</ul>
document.querySelector('.track-row').addEventListener('click', () => {
console.log('клик по строке')
})
document.querySelector('.playlist').addEventListener('click', () => {
console.log('клик всплыл к playlist')
})
Клик по кнопке «▶» вызовет оба обработчика — сначала на строке, затем на списке (если не остановлено stopPropagation).
Паттерн делегирования #
Один listener на контейнере; цель определяется через event.target:
const playlist = document.querySelector('.playlist')
playlist.addEventListener('click', (event) => {
const actionBtn = event.target.closest('[data-action]')
if (!actionBtn || !playlist.contains(actionBtn)) return
const row = actionBtn.closest('[data-track-id]')
if (!row) return
const trackId = row.dataset.trackId
const action = actionBtn.dataset.action
if (action === 'play') startTrack(trackId)
if (action === 'remove') removeTrack(trackId)
})
Новые строки, добавленные через append, автоматически участвуют в обработке — повторно вешать listener не нужно.
closest и contains #
event.target.closest(selector)— ближайший предок (или сам элемент), подходящий под селектор.container.contains(node)— проверка, что клик произошёл внутри контейнера, а не в выпавшем портале.
Для таблицы заказов на дашборде:
document.querySelector('.orders-table').addEventListener('click', (event) => {
const row = event.target.closest('tr[data-order-id]')
if (!row) return
openOrderDetails(row.dataset.orderId)
})
Когда не делегировать #
- События, не всплывающие:
focus,blur,mouseenter,mouseleave— вешайте на конкретный элемент или используйтеfocusin/focusout(они всплывают). - Очень частые события (
mousemove,scroll) на большом контейнере — фильтруйте рано или вешайте на узкий элемент. - Если нужен
stopPropagationна дочернем элементе — убедитесь, что родительский делегат не конфликтует с ожиданиями.
Итог #
- События всплывают от target к корню; capture идёт сверху вниз.
- Делегирование: один
addEventListenerна родителе +event.target.closest. - Динамические списки (плейлист, корзина) не требуют перевешивания обработчиков.
containsи раннийreturnзащищают от ложных срабатываний.