Делегирование событий
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Сто кнопок — сто обработчиков? Не обязательно: событие всё равно всплывает до контейнера, поэтому одного слушателя на родителе хватает и на текущие элементы, и на те, что появятся через минуту.
Клик по пункту списка не остаётся на пункте. Он всплывает через <ul>, <body> и дальше до document — и по дороге срабатывают все обработчики, которые там висят. Значит, слушателю необязательно сидеть на каждом элементе: достаточно одного на общем родителе. Это и есть делегирование — родитель принимает событие и сам разбирается, кто его породил.
Внутри обработчика различай две ссылки. event.currentTarget — элемент, на котором висит слушатель, то есть контейнер. event.target — то место, куда событие реально пришло.
Выигрыш не только в количестве функций в памяти. Пропадает вся возня со списком подписок: не нужно обходить элементы при каждой перерисовке, не нужно помнить, кому отписаться, чтобы не оставить висящих ссылок. На этом же приёме стоят обработчики в React: слушатель там один, на корне приложения, — как это устроено, разбирает статья «Обработчики в JSX против addEventListener».
Вешай обработчик на ближайшего общего предка, а не сразу на document. Чем уже контейнер, тем меньше чужих событий проходит через твой код и тем короче отладка.
Сравнивать event.target с ожидаемым элементом в лоб получается ровно до первой иконки внутри кнопки: клик придёт по <span>, а не по кнопке. Поэтому от цели идут вверх. closest(селектор) возвращает ближайшего подходящего предка — или сам элемент, если он подходит, — и null, если такого нет. Сам метод вместе с соседями matches и contains вводит статья «matches, closest и поиск вверх по дереву».
Дальше шаблон один и тот же: нашли элемент — работаем, не нашли — тихо выходим. Второй closest удобен, чтобы от кнопки подняться к строке, к которой она относится, и достать её данные. И помни: closest не знает про границы твоего контейнера и при слушателе на document может уехать выше — поэтому проверка container.contains(...) лишней не бывает.
Обработчик на контейнере ничего не знает о своих детях — он ждёт всплывшее событие. Поэтому пункт, созданный уже после подписки, обслуживается тем же слушателем: всплытие не спрашивает, когда элемент родился.
Это же спасает при полной перерисовке. Если задать контейнеру новый innerHTML, старые узлы уничтожаются вместе со всеми обработчиками, которые висели прямо на них. Слушатель на контейнере переживает перерисовку, потому что сам контейнер остался на месте.
Повесь тот же обработчик прямо на <li> — и после присваивания innerHTML клик перестанет что-либо делать: элемент, к которому он относился, больше не существует.
Когда действий много, разбирать их лесенкой if по классам быстро надоедает. Проще положить имя действия в data-action: разметка объявляет, что делает кнопка, а обработчик достаёт функцию из таблицы по этому имени.
Новая кнопка теперь — строка в разметке и функция в объекте actions; сам обработчик трогать не нужно. Данные для действия кладут рядом, в соседние data-*: data-id, data-sku — и читают через dataset.
Делегирование держится на всплытии — значит, события, которые не всплывают, до контейнера просто не доедут. Самая частая ловушка: focus и blur. Проверим на синтетических событиях, у которых флаг bubbles виден прямо в коде.
| Событие | Всплывает | Как делегировать |
|---|---|---|
| click, input, change, submit, keydown | да | обычный слушатель на контейнере |
| focus, blur | нет | focusin / focusout либо третий аргумент true (фаза погружения) |
| mouseenter, mouseleave | нет | mouseover / mouseout и проверка relatedTarget |
| scroll у элемента | нет | слушать сам прокручиваемый элемент |
| load, error у img | нет | слушатель с capture: true на контейнере |
Есть и поводы отказаться, не связанные со всплытием. Потомок вызвал stopPropagation — и до контейнера ничего не дошло; чем это плохо и чем остановку заменить, разбирает статья «stopPropagation и когда он вреден». Событие частое (mousemove, pointermove), а контейнер большой — твой код дёргается на каждое движение мыши, даже когда курсор далеко от интересных элементов. Общего предка у элементов нет, и остаётся вешать на document — тогда через обработчик пойдут вообще все клики страницы, включая чужие.
Отсюда правило: не гаси всплытие без нужды. Обработчик на кнопке не видит, кто ещё рассчитывает на это событие выше по дереву — и молча ломает соседний код.
- Делегирование — один обработчик на контейнере вместо обработчика на каждом элементе; всё держится на всплытии.
- event.currentTarget — контейнер со слушателем, event.target — элемент, где событие возникло.
- closest поднимается от target вверх и возвращает null, если подходящего предка нет; проверка на null обязательна.
- Элементы, добавленные после подписки, и полная перерисовка через innerHTML делегированию не мешают: слушатель живёт на контейнере.
- Не всплывают focus, blur, mouseenter, mouseleave, scroll у элемента — для них focusin/focusout, mouseover/mouseout или фаза погружения.
1/3В делегирующем обработчике на #panel кликнули по span, который лежит внутри button. Что окажется в event.target?
2/3Какое из событий не всплывает и потому не годится для делегирования напрямую?
3/3Зачем после const item = event.target.closest(".row") нужна проверка на null?
Удаление строк одним обработчиком
В корзине три строки, у каждой своя кнопка удаления. Повесь один обработчик на #cart: по клику на .remove он должен напечатать data-sku строки и удалить саму строку. Клик мимо кнопки не должен ничего ломать.
От цели поднимаемся к кнопке, от кнопки — к строке с данными.
Проверка if (!button) return; обязательна: клик по названию товара тоже придёт в обработчик, и closest вернёт там null. Приятный побочный эффект: строки, добавленные в корзину позже, удаляются тем же кодом — переподписываться не нужно.
Форма слышит любое поле
Событие input всплывает, значит форме хватит одного слушателя на все поля. Собери значения в объект data, ключ — атрибут name поля.
Обработчик один, полей сколько угодно — включая те, что подставит шаблон позже.
Обрати внимание на { bubbles: true } в тесте: настоящее событие input всплывает само, а вот созданное вручную — только если попросить. Забудешь флаг — и делегирующий обработчик честно промолчит.