Делегирование событий (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: 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 – несколько десятков строк на любое число элементов.