Всплытие и делегирование

Всплытие и делегирование #

Всплытие (bubbling) — распространение события от целевого элемента вверх по DOM к document. Делегирование — один обработчик на родителе вместо сотен на строках списка. Для динамического плейлиста и таблицы заказов JSFlow это основной паттерн производительности и простоты.


Три фазы #

  1. Capture — от window к цели (если capture: true).
  2. Target — на элементе, где произошло действие.
  3. 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 защищают от ложных срабатываний.

См. также #