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

Приём в JavaScript, когда один обработчик вешается на контейнер и ловит события от всех потомков через всплытие, определяя цель методом event.target.closest().

4 минуты чтения

Делегирование событий (event delegation) – классический паттерн работы с DOM и один из стандартных вопросов на собеседованиях по JavaScript. Вместо того чтобы назначать обработчик каждой кнопке списка, один обработчик вешают на общего предка – и он ловит события всех потомков, включая элементы, которых на момент загрузки страницы ещё не существовало. Паттерн часто сочетают с preventDefault() (перехват кликов по ссылкам) и data-атрибутами (декларативное описание действий прямо в разметке).

Как это работает

Механизм держится на всплытии (event bubbling): событие срабатывает на самом вложенном элементе и поднимается по цепочке предков – от кнопки к её контейнеру, затем к body и document. Обработчик на контейнере получает события всех потомков, а где именно произошёл клик, показывает свойство event.target. У всплытия есть и обратная фаза – погружение (capturing): событие сначала спускается от document к цели. По умолчанию она выключена и включается опцией { capture: true } – через неё, кстати, можно делегировать даже невсплывающий focus. Типовой код с поиском цели через closest():

table.addEventListener('click', (event) => {
  const td = event.target.closest('td'); // клик мог прийти по вложенному тегу
  if (!td) return;                       // клик мимо ячеек
  if (!table.contains(td)) return;       // td из вложенной таблицы – отсекаем
  highlight(td);
});

Метод closest() здесь обязателен: если внутри ячейки лежит вложенный тег (например, <strong>), event.target укажет на него, а не на саму ячейку. Проверка table.contains(td) страхует от ловушки, о которой почти не пишут: closest() может найти подходящую под селектор ячейку вложенной таблицы или стороннего виджета, и обработчик сработает не на своём элементе.

event.target и event.currentTarget – в чём разница

event.target и event.currentTarget при всплытии
СвойствоЧто содержитПоведение при всплытии
event.targetЭлемент, на котором событие изначально произошлоНе меняется на всём пути всплытия
event.currentTargetЭлемент, на котором висит текущий обработчикМеняется на каждом уровне цепочки

В делегировании работают именно с event.target: currentTarget всегда укажет на контейнер, на котором висит обработчик, – по нему удобно разве что отличать «свой» контейнер, когда один обработчик обслуживает несколько блоков.

Обязательное условие паттерна – событие должно всплывать. Не все события это делают, и это первая причина «сломанного» делегирования:

Какие события всплывают и чем заменить остальные
СобытиеВсплываетЗамена для делегирования
click, input, keydownДаЗамена не нужна
focus / blurНетfocusin / focusout либо focus с capture: true
mouseenter / mouseleaveНетmouseover / mouseout

У mouseenter есть и вторая проблема: при глубокой вложенности он порождает лавину событий, поэтому MDN прямо рекомендует mouseover для таких случаев. Ещё одна ловушка – event.stopPropagation() во вложенном обработчике: событие не дойдёт до предка, и делегирование молча перестанет работать. Поэтому в низкоуровневых обработчиках внутри делегируемого контейнера stopPropagation() лучше не вызывать.

Зачем это нужно

Три измеримые выгоды паттерна:

  • Экономия памяти – один listener вместо сотен: для таблицы на тысячу ячеек или ленты товаров разница ощутимая.
  • Динамические элементы – обработчик на предке автоматически охватывает узлы, добавленные позже: кнопка «показать ещё», результаты живого поиска, виджеты, которые перерисовываются по таймеру setInterval().
  • Меньше кода – одна точка инициализации, проще поддержка и нет «забытых» обработчиков после перерисовки DOM.

Для владельца сайта всё это конвертируется в отзывчивый интерфейс на слабых устройствах и меньший объём кода, который нужно поддерживать и тестировать. А для разработчика тема имеет ещё одно измерение: делегирование входит в стандартный набор интервью-вопросов по JavaScript – его спрашивают, чтобы проверить понимание всплытия и работы с DOM в целом.

Цена невелика, но она есть: обработчик на предке срабатывает на каждое событие в контейнере, включая нецелевые, – это небольшая дополнительная нагрузка на CPU, обычно незаметная. Поэтому для двух-трёх статических кнопок делегирование избыточно: прямой addEventListener проще и читается лучше. Паттерн раскрывается на больших списках, таблицах и любом динамически генерируемом контенте.

Пример

Развитие паттерна – «поведение» через data-атрибуты: действие описывается прямо в разметке, а один обработчик на контейнере читает его из dataset и вызывает нужную функцию:

<div id="menu">
  <button data-action="save">Сохранить</button>
  <button data-action="load">Загрузить</button>
</div>

<script>
menu.addEventListener('click', (event) => {
  const action = event.target.dataset.action;
  if (action) actions[action]();
});
</script>

Так я собираю аккордеоны и табы в шаблонах Битрикс-компонентов: элементы списка выводятся циклом, их количество заранее неизвестно, а весь JavaScript – один делегированный обработчик, который по data-атрибуту находит панель и переключает ей aria-expanded. При догрузке элементов по AJAX перевешивать ничего не нужно: клики по новым кнопкам всплывают до того же контейнера и обрабатываются тем же кодом. Тот же подход масштабируется на корзину, фильтры каталога, выпадающие меню: действия описаны в разметке, а весь JavaScript – несколько десятков строк на любое число элементов.

Частые вопросы

Что такое делегирование событий простыми словами?

Это приём, при котором один обработчик вешается на общего предка вместо множества обработчиков на каждом элементе. Событие всплывает от нажатого элемента вверх по DOM, обработчик ловит его на контейнере и по event.target определяет, где именно произошёл клик. Так делают меню, таблицы и списки с сотнями однотипных элементов.

Как делегирование работает с динамически добавленными элементами?

Обработчик висит на предке, а не на самих элементах, поэтому новые узлы подхватываются автоматически. Кнопке, добавленной по AJAX или таймеру, не нужно ничего назначать: её клик всплывёт до контейнера и попадёт в тот же обработчик. Это главное преимущество паттерна для лент, живого поиска и подгрузки «показать ещё».

Чем event.target отличается от event.currentTarget?

event.target – элемент, на котором событие изначально произошло; он не меняется при всплытии. event.currentTarget – элемент, на котором висит выполняющийся прямо сейчас обработчик; на каждом уровне всплытия он свой. В делегировании используют target: currentTarget всегда указывает на контейнер.

Зачем в делегировании нужен метод closest()?

Клик может прийти по вложенному тегу: если внутри кнопки лежит span или иконка, event.target укажет на них, а не на кнопку. event.target.closest(селектор) поднимается по предкам и находит нужный элемент. Дополнительно стоит проверять container.contains(найденное), чтобы отсечь совпадения из вложенных виджетов.

Какие события нельзя делегировать?

Те, что не всплывают: focus и blur заменяют на focusin и focusout (либо вешают focus с capture: true), mouseenter и mouseleave – на mouseover и mouseout. Остальные популярные события (click, input, keydown) всплывают и делегируются без замен.

Делегирование ухудшает производительность?

Наоборот: один обработчик вместо сотен экономит память, особенно на больших таблицах и списках. Цена в другом – обработчик на предке срабатывает на каждое событие в контейнере, даже нецелевое, что даёт небольшую нагрузку на CPU. На практике она обычно незаметна; а для пары статических кнопок паттерн просто не нужен.

Почему делегирование не работает?

Три частые причины. Событие не всплывает (focus, mouseenter) – нужна всплывающая замена вроде focusin или mouseover. Вложенный обработчик вызвал event.stopPropagation() – событие не дошло до предка. Клик пришёл по вложенному тегу, а код сравнивает event.target напрямую вместо event.target.closest().

Материалы по теме

Валентина Меланина

Нужна консультация?

Разберу ваш сайт и покажу точки роста

Если хотите понять, как этот термин применить к вашему проекту — начнём с аудита.