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

делегирование≈ 6 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Сто кнопок — сто обработчиков? Не обязательно: событие всё равно всплывает до контейнера, поэтому одного слушателя на родителе хватает и на текущие элементы, и на те, что появятся через минуту.

Разделы темы:·

Клик по пункту списка не остаётся на пункте. Он всплывает через <ul>, <body> и дальше до document — и по дороге срабатывают все обработчики, которые там висят. Значит, слушателю необязательно сидеть на каждом элементе: достаточно одного на общем родителе. Это и есть делегирование — родитель принимает событие и сам разбирается, кто его породил.

Внутри обработчика различай две ссылки. event.currentTarget — элемент, на котором висит слушатель, то есть контейнер. event.target — то место, куда событие реально пришло.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Выигрыш не только в количестве функций в памяти. Пропадает вся возня со списком подписок: не нужно обходить элементы при каждой перерисовке, не нужно помнить, кому отписаться, чтобы не оставить висящих ссылок. На этом же приёме стоят обработчики в React: слушатель там один, на корне приложения, — как это устроено, разбирает статья «Обработчики в JSX против addEventListener».

Вешай обработчик на ближайшего общего предка, а не сразу на document. Чем уже контейнер, тем меньше чужих событий проходит через твой код и тем короче отладка.

Сравнивать event.target с ожидаемым элементом в лоб получается ровно до первой иконки внутри кнопки: клик придёт по <span>, а не по кнопке. Поэтому от цели идут вверх. closest(селектор) возвращает ближайшего подходящего предка — или сам элемент, если он подходит, — и null, если такого нет. Сам метод вместе с соседями matches и contains вводит статья «matches, closest и поиск вверх по дереву».

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дальше шаблон один и тот же: нашли элемент — работаем, не нашли — тихо выходим. Второй closest удобен, чтобы от кнопки подняться к строке, к которой она относится, и достать её данные. И помни: closest не знает про границы твоего контейнера и при слушателе на document может уехать выше — поэтому проверка container.contains(...) лишней не бывает.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Это же спасает при полной перерисовке. Если задать контейнеру новый innerHTML, старые узлы уничтожаются вместе со всеми обработчиками, которые висели прямо на них. Слушатель на контейнере переживает перерисовку, потому что сам контейнер остался на месте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Повесь тот же обработчик прямо на <li> — и после присваивания innerHTML клик перестанет что-либо делать: элемент, к которому он относился, больше не существует.

Когда действий много, разбирать их лесенкой if по классам быстро надоедает. Проще положить имя действия в data-action: разметка объявляет, что делает кнопка, а обработчик достаёт функцию из таблицы по этому имени.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Новая кнопка теперь — строка в разметке и функция в объекте actions; сам обработчик трогать не нужно. Данные для действия кладут рядом, в соседние data-*: data-id, data-sku — и читают через dataset.

Делегирование держится на всплытии — значит, события, которые не всплывают, до контейнера просто не доедут. Самая частая ловушка: focus и blur. Проверим на синтетических событиях, у которых флаг bubbles виден прямо в коде.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
СобытиеВсплываетКак делегировать
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 — тогда через обработчик пойдут вообще все клики страницы, включая чужие.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсюда правило: не гаси всплытие без нужды. Обработчик на кнопке не видит, кто ещё рассчитывает на это событие выше по дереву — и молча ломает соседний код.

  • Делегирование — один обработчик на контейнере вместо обработчика на каждом элементе; всё держится на всплытии.
  • 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 строки и удалить саму строку. Клик мимо кнопки не должен ничего ломать.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

От цели поднимаемся к кнопке, от кнопки — к строке с данными.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверка if (!button) return; обязательна: клик по названию товара тоже придёт в обработчик, и closest вернёт там null. Приятный побочный эффект: строки, добавленные в корзину позже, удаляются тем же кодом — переподписываться не нужно.

Форма слышит любое поле

Событие input всплывает, значит форме хватит одного слушателя на все поля. Собери значения в объект data, ключ — атрибут name поля.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик один, полей сколько угодно — включая те, что подставит шаблон позже.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обрати внимание на { bubbles: true } в тесте: настоящее событие input всплывает само, а вот созданное вручную — только если попросить. Забудешь флаг — и делегирующий обработчик честно промолчит.