← JavaScript/Всплытие и делегирование событий#106 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

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

В Trello доска содержит сотни карточек. Если на каждую карточку вешать отдельный обработчик — это сотни слушателей событий в памяти. Вместо этого один обработчик на контейнер ловит клики по всем карточкам — это и есть делегирование событий, основанное на всплытии.

Какую проблему решает

При добавлении новых элементов динамически нужно вешать на них обработчики. Делегирование решает обе проблемы: один обработчик на родителе работает для всех дочерних элементов, включая те, что появятся позже.

На основе предыдущих уроков

  • События — addEventListener, event объект
  • DOM — дерево элементов, querySelector
  • Навигация DOM — closest() для поиска предка
  • Всплытие (Bubbling)

    Когда происходит событие на элементе, оно сначала обрабатывается на нём, затем поднимается к родителю, и так до document:

    click на span внутри li внутри ul
      -> обработчики на span
      -> обработчики на li
      -> обработчики на ul
      -> обработчики на body
      -> обработчики на document

    event.target vs event.currentTarget

    ul.addEventListener('click', (event) => {
      event.target         // элемент, на котором произошёл клик (span внутри li)
      event.currentTarget  // элемент, на котором висит обработчик (ul)
    })

    event.target — реальный источник. event.currentTarget — элемент с обработчиком.

    Остановка всплытия

    element.addEventListener('click', (event) => {
      event.stopPropagation()  // событие не пойдёт дальше вверх
    })

    Используй с осторожностью: stopPropagation может сломать другие обработчики выше по дереву.

    Погружение (Capturing)

    По умолчанию обработчики срабатывают при всплытии. Третий аргумент true включает режим захвата при погружении (событие идёт сверху вниз):

    element.addEventListener('click', handler, true)   // capturing — при погружении
    element.addEventListener('click', handler, false)  // bubbling (по умолчанию)
    element.addEventListener('click', handler, { capture: true, once: true })

    Делегирование событий

    Один обработчик на родителе вместо N обработчиков на дочерних элементах:

    // Плохо: 1000 карточек Trello — 1000 обработчиков
    cards.forEach(card => card.addEventListener('click', handleCardClick))
    
    // Хорошо: один обработчик на контейнер
    board.addEventListener('click', (event) => {
      const card = event.target.closest('.card')
      if (!card) return  // клик был не на карточке
      handleCardClick(card)
    })

    Преимущества делегирования:

  • Меньше обработчиков — меньше памяти
  • Работает для динамически добавленных элементов
  • Проще управлять: один обработчик вместо N
  • closest() в делегировании

    taskList.addEventListener('click', (event) => {
      const task = event.target.closest('li[data-id]')
      if (!task) return
    
      const action = event.target.dataset.action
      if (action === 'delete') deleteTask(task.dataset.id)
      if (action === 'complete') completeTask(task.dataset.id)
    })

    Типичные ошибки

    Ошибка 1: не проверяем target — обработчик срабатывает на контейнере

    // Неправильно — обрабатывает клики по пустому месту контейнера
    ul.addEventListener('click', (e) => {
      deleteItem(e.target.dataset.id)  // e.target может быть сам ul!
    })
    
    // Правильно
    ul.addEventListener('click', (e) => {
      const li = e.target.closest('li')
      if (!li) return  // клик не на li — игнорируем
      deleteItem(li.dataset.id)
    })

    Ошибка 2: stopPropagation везде — ломает делегирование

    // Если кнопка внутри карточки останавливает всплытие —
    // обработчик на контейнере не получит событие!
    btn.addEventListener('click', (e) => {
      e.stopPropagation()  // карточка больше не реагирует на клик по кнопке
      doSomething()
    })

    Ошибка 3: addEventListener внутри цикла — дублирование обработчиков

    // Неправильно — при каждом рендере добавляются новые обработчики
    function render(items) {
      items.forEach(item => {
        const el = getElement(item.id)
        el.addEventListener('click', handler)  // дублируется при каждом render()!
      })
    }
    
    // Правильно — один обработчик на контейнер через делегирование
    container.addEventListener('click', (e) => {
      const item = e.target.closest('.item')
      if (item) handler(item)
    })

    В реальных проектах

  • Trello/Jira: обработчик на доске ловит клики по всем карточкам
  • Таблицы данных: один обработчик на tbody для всех строк
  • Выпадающие меню: делегирование для пунктов меню
  • Infinite scroll: новые элементы подхватываются без дополнительных обработчиков
  • Примеры

    Симуляция системы делегирования событий без реального DOM

    // Симуляция делегирования событий через EventBus
    function createEventSystem() {
      const handlers = new Map()
    
      return {
        on(selector, event, handler) {
          const key = event + ':' + selector
          if (!handlers.has(key)) handlers.set(key, [])
          handlers.get(key).push(handler)
        },
    
        dispatch(targetPath, event, data) {
          if (data === undefined) data = {}
          console.log('Event "' + event + '" от "' + targetPath + '"')
    
          handlers.forEach(function(handlerList, key) {
            const colonIdx = key.indexOf(':')
            const evtName  = key.slice(0, colonIdx)
            const selector = key.slice(colonIdx + 1)
    
            if (evtName === event && targetPath.includes(selector)) {
              handlerList.forEach(function(h) {
                h(Object.assign({ target: targetPath, selector }, data))
              })
            }
          })
        }
      }
    }
    
    const board = createEventSystem()
    
    // Один обработчик на "доску" ловит клики по любой "карточке"
    board.on('.card', 'click', function(e) {
      console.log('Карточка нажата:', e.target)
    })
    
    // Отдельный обработчик для кнопки удаления
    board.on('.btn-delete', 'click', function(e) {
      console.log('Удалить задачу:', e.id)
    })
    
    // Симулируем клики
    board.dispatch('.board .card .card-title', 'click', { id: 'task-1' })
    // Event "click" от ".board .card .card-title"
    // Карточка нажата: .board .card .card-title
    
    board.dispatch('.board .card .btn-delete', 'click', { id: 'task-2' })
    // Карточка нажата: ...
    // Удалить задачу: task-2
    
    board.dispatch('.board .column-header', 'click')
    // ничего — '.card' нет в пути

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

    В Trello доска содержит сотни карточек. Если на каждую карточку вешать отдельный обработчик — это сотни слушателей событий в памяти. Вместо этого один обработчик на контейнер ловит клики по всем карточкам — это и есть делегирование событий, основанное на всплытии.

    Какую проблему решает

    При добавлении новых элементов динамически нужно вешать на них обработчики. Делегирование решает обе проблемы: один обработчик на родителе работает для всех дочерних элементов, включая те, что появятся позже.

    На основе предыдущих уроков

  • События — addEventListener, event объект
  • DOM — дерево элементов, querySelector
  • Навигация DOM — closest() для поиска предка
  • Всплытие (Bubbling)

    Когда происходит событие на элементе, оно сначала обрабатывается на нём, затем поднимается к родителю, и так до document:

    click на span внутри li внутри ul
      -> обработчики на span
      -> обработчики на li
      -> обработчики на ul
      -> обработчики на body
      -> обработчики на document

    event.target vs event.currentTarget

    ul.addEventListener('click', (event) => {
      event.target         // элемент, на котором произошёл клик (span внутри li)
      event.currentTarget  // элемент, на котором висит обработчик (ul)
    })

    event.target — реальный источник. event.currentTarget — элемент с обработчиком.

    Остановка всплытия

    element.addEventListener('click', (event) => {
      event.stopPropagation()  // событие не пойдёт дальше вверх
    })

    Используй с осторожностью: stopPropagation может сломать другие обработчики выше по дереву.

    Погружение (Capturing)

    По умолчанию обработчики срабатывают при всплытии. Третий аргумент true включает режим захвата при погружении (событие идёт сверху вниз):

    element.addEventListener('click', handler, true)   // capturing — при погружении
    element.addEventListener('click', handler, false)  // bubbling (по умолчанию)
    element.addEventListener('click', handler, { capture: true, once: true })

    Делегирование событий

    Один обработчик на родителе вместо N обработчиков на дочерних элементах:

    // Плохо: 1000 карточек Trello — 1000 обработчиков
    cards.forEach(card => card.addEventListener('click', handleCardClick))
    
    // Хорошо: один обработчик на контейнер
    board.addEventListener('click', (event) => {
      const card = event.target.closest('.card')
      if (!card) return  // клик был не на карточке
      handleCardClick(card)
    })

    Преимущества делегирования:

  • Меньше обработчиков — меньше памяти
  • Работает для динамически добавленных элементов
  • Проще управлять: один обработчик вместо N
  • closest() в делегировании

    taskList.addEventListener('click', (event) => {
      const task = event.target.closest('li[data-id]')
      if (!task) return
    
      const action = event.target.dataset.action
      if (action === 'delete') deleteTask(task.dataset.id)
      if (action === 'complete') completeTask(task.dataset.id)
    })

    Типичные ошибки

    Ошибка 1: не проверяем target — обработчик срабатывает на контейнере

    // Неправильно — обрабатывает клики по пустому месту контейнера
    ul.addEventListener('click', (e) => {
      deleteItem(e.target.dataset.id)  // e.target может быть сам ul!
    })
    
    // Правильно
    ul.addEventListener('click', (e) => {
      const li = e.target.closest('li')
      if (!li) return  // клик не на li — игнорируем
      deleteItem(li.dataset.id)
    })

    Ошибка 2: stopPropagation везде — ломает делегирование

    // Если кнопка внутри карточки останавливает всплытие —
    // обработчик на контейнере не получит событие!
    btn.addEventListener('click', (e) => {
      e.stopPropagation()  // карточка больше не реагирует на клик по кнопке
      doSomething()
    })

    Ошибка 3: addEventListener внутри цикла — дублирование обработчиков

    // Неправильно — при каждом рендере добавляются новые обработчики
    function render(items) {
      items.forEach(item => {
        const el = getElement(item.id)
        el.addEventListener('click', handler)  // дублируется при каждом render()!
      })
    }
    
    // Правильно — один обработчик на контейнер через делегирование
    container.addEventListener('click', (e) => {
      const item = e.target.closest('.item')
      if (item) handler(item)
    })

    В реальных проектах

  • Trello/Jira: обработчик на доске ловит клики по всем карточкам
  • Таблицы данных: один обработчик на tbody для всех строк
  • Выпадающие меню: делегирование для пунктов меню
  • Infinite scroll: новые элементы подхватываются без дополнительных обработчиков
  • Примеры

    Симуляция системы делегирования событий без реального DOM

    // Симуляция делегирования событий через EventBus
    function createEventSystem() {
      const handlers = new Map()
    
      return {
        on(selector, event, handler) {
          const key = event + ':' + selector
          if (!handlers.has(key)) handlers.set(key, [])
          handlers.get(key).push(handler)
        },
    
        dispatch(targetPath, event, data) {
          if (data === undefined) data = {}
          console.log('Event "' + event + '" от "' + targetPath + '"')
    
          handlers.forEach(function(handlerList, key) {
            const colonIdx = key.indexOf(':')
            const evtName  = key.slice(0, colonIdx)
            const selector = key.slice(colonIdx + 1)
    
            if (evtName === event && targetPath.includes(selector)) {
              handlerList.forEach(function(h) {
                h(Object.assign({ target: targetPath, selector }, data))
              })
            }
          })
        }
      }
    }
    
    const board = createEventSystem()
    
    // Один обработчик на "доску" ловит клики по любой "карточке"
    board.on('.card', 'click', function(e) {
      console.log('Карточка нажата:', e.target)
    })
    
    // Отдельный обработчик для кнопки удаления
    board.on('.btn-delete', 'click', function(e) {
      console.log('Удалить задачу:', e.id)
    })
    
    // Симулируем клики
    board.dispatch('.board .card .card-title', 'click', { id: 'task-1' })
    // Event "click" от ".board .card .card-title"
    // Карточка нажата: .board .card .card-title
    
    board.dispatch('.board .card .btn-delete', 'click', { id: 'task-2' })
    // Карточка нажата: ...
    // Удалить задачу: task-2
    
    board.dispatch('.board .column-header', 'click')
    // ничего — '.card' нет в пути

    Задание

    Реализуй объект EventBus с методами on(event, selector, handler) и emit(event, targetPath, data). При emit должны вызываться все обработчики, у которых event совпадает и selector является подстрокой targetPath (симуляция всплытия). Также реализуй off(event, selector, handler) для удаления обработчика.

    Подсказка

    В on: this._handlers.push({ event, selector, handler }). В emit: this._handlers.filter(h => h.event === event && targetPath.includes(h.selector)).forEach(h => h.handler(data)).

    Загружаем среду выполнения...
    Загружаем AI-помощника...