События в браузере: клики, формы и делегирование в JS
Как обрабатывать клики и отправку форм, почему обработчик на каждой кнопке ломается на динамическом списке и как одно событие на родителе решает проблему делегированием.

Вы навесили addEventListener('click') на каждую кнопку в списке, а потом дорисовали в этот список ещё десять элементов через fetch — и клики по новым перестали работать. Обработчики висели на старых узлах, а новые про них не знают. Разберём, как события устроены в браузере, почему прямая привязка ломается на динамическом контенте и как делегирование чинит это одной строкой. Ориентируемся на современный DOM API — он стабилен во всех актуальных браузерах, отдельных полифилов не требует.
Как событие проходит по дереву
Когда вы кликаете по кнопке внутри списка, браузер не отправляет событие сразу этой кнопке. Событие проходит три фазы. Сначала погружение (capturing): от document вниз к целевому элементу. Потом цель (target): собственно кликнутый элемент. Потом всплытие (bubbling): обратно вверх, от цели к document.
По умолчанию addEventListener слушает фазу всплытия. Это значит: обработчик, повешенный на <ul>, сработает и при клике по любому <li> внутри — потому что событие всплывёт от <li> к <ul>. На этом и строится делегирование.
Базовая привязка
// Простой случай: одна кнопка, известная заранее
const knopka = document.querySelector('#submit');
knopka.addEventListener('click', (event) => {
console.log('Клик по', event.target);
});
Здесь event.target — элемент, по которому реально кликнули, а event.currentTarget — элемент, на котором висит обработчик. Для одной кнопки они совпадают. Различие станет важным ниже.
Где прямая привязка ломается
Проблема из первого абзаца: список товаров, у каждого — кнопка «В корзину». Вы вешаете обработчик в цикле:
// Так делать не стоит на динамическом списке
document.querySelectorAll('.tovar .v-korzinu').forEach((btn) => {
btn.addEventListener('click', dobavitVKorzinu);
});
Три беды сразу:
- Новые элементы не охвачены.
querySelectorAllвернул срез DOM на момент вызова. Элементы, добавленные позже черезfetchилиinnerHTML, обработчика не получат. - Память. На сотнях элементов — сотни обработчиков. Каждый удерживает ссылку на функцию и узел.
- Пересборка. Перерисовали список — старые узлы с обработчиками ушли в мусор, надо навешивать заново и не забыть снять старое, если узлы переиспользуются.
Делегирование: один обработчик на родителе
Вместо обработчика на каждой кнопке вешаем один на общего родителя, который не пересоздаётся. Внутри проверяем, куда именно кликнули, через event.target.closest().
const spisok = document.querySelector('#tovary');
spisok.addEventListener('click', (event) => {
// Ищем ближайшую кнопку вверх по дереву от точки клика
const knopka = event.target.closest('.v-korzinu');
if (!knopka) return; // клик мимо кнопки — игнорируем
if (!spisok.contains(knopka)) return; // подстраховка на всякий случай
const tovar = knopka.closest('.tovar');
dobavitVKorzinu(tovar.dataset.id);
});
closest() идёт от event.target вверх и возвращает первый элемент, подходящий под селектор, или null. Это ключ к делегированию: вы кликаете по иконке внутри кнопки, event.target — иконка, но closest('.v-korzinu') поднимется до самой кнопки. Проверка через сравнение event.target.classList тут сломалась бы.
Теперь новые товары работают сразу после вставки — обработчик один и висит на родителе, который никуда не девается.
Делегирование меняет вопрос «на что вешать обработчик» на вопрос «куда кликнули». Первый вопрос вы задаёте один раз при загрузке, второй — браузер решает за вас при каждом клике.
Прямая привязка против делегирования
| Критерий | Обработчик на каждом элементе | Делегирование на родителе |
|---|---|---|
| Динамический контент | Не работает без повторной привязки | Работает сразу |
| Число обработчиков | По одному на элемент | Один на весь список |
| Где взять контекст | Прямо в замыкании | Через closest и dataset |
| Когда проще | Один-два фиксированных элемента | Список, таблица, любой повторяющийся набор |
Формы: submit, а не click по кнопке
Частая ошибка новичка — ловить клик по кнопке отправки. Так вы пропустите отправку по Enter из текстового поля и не получите валидацию формы. Слушайте событие submit на самой форме.
const forma = document.querySelector('#zakaz');
forma.addEventListener('submit', (event) => {
event.preventDefault(); // отменяем перезагрузку страницы
// FormData собирает все поля с атрибутом name
const data = new FormData(forma);
const telefon = data.get('telefon');
if (!telefon) {
console.log('Телефон обязателен');
return;
}
fetch('/api/zakaz', { method: 'POST', body: data })
.then((res) => res.ok
? console.log('Отправлено')
: console.log('Ошибка сервера', res.status));
});
event.preventDefault() отменяет действие по умолчанию — здесь перезагрузку страницы после отправки. Без него браузер уйдёт по action формы, и ваш fetch ничего не успеет.
Событие submit всплывает — значит, форму тоже можно обрабатывать делегированием, если форм на странице несколько и они рисуются динамически. Но submit есть только у <form>, поэтому обработчик обычно вешают прямо на форму.
Ввод в реальном времени: input против change
Два похожих события легко перепутать:
- input — на каждое изменение значения: каждый введённый символ. Для живого поиска, счётчика символов, мгновенной валидации.
- change — когда поле теряет фокус после изменения или пользователь выбрал значение в
<select>. Для чекбоксов, радиокнопок, выпадающих списков.
const poisk = document.querySelector('#poisk');
poisk.addEventListener('input', (event) => {
const zapros = event.target.value.trim();
// На каждый символ дёргать сервер не надо — см. раздел про подводные камни
console.log('Ищем:', zapros);
});
Что ломается на практике
- Клик по вложенному элементу. Внутри кнопки —
<svg>или<span>. Тогдаevent.target— вложенный узел, а не кнопка. Всегда поднимайтесь черезclosest(), не сравнивайтеevent.targetнапрямую. - События, которые не всплывают.
focusиblurне всплывают — делегирование через родителя для них не сработает. Используйте всплывающие аналогиfocusinиfocusoutлибо передайте третьим аргументом{ capture: true }, чтобы ловить на фазе погружения. - Живой поиск без задержки. Обработчик
input, который бьёт по серверу на каждый символ, — это лишние запросы. Оборачивайте вызов в debounce (задержку с отменой предыдущего таймера); реализуйте сами черезsetTimeout/clearTimeout— отдельного встроенного debounce в DOM API нет. - Забытый preventDefault. Ссылка-кнопка (
<a href="#">) прокручивает страницу вверх, форма перезагружается. Отменяйте действие по умолчанию явно. - Утечка обработчиков. Если элемент удаляется, а обработчик добавлен на него самого — при пересоздании навешивайте заново или снимайте через
removeEventListenerс той же ссылкой на функцию. Анонимную стрелку снять нельзя — её нельзя передать вremoveEventListener.
Что делать дальше
Возьмите один список или таблицу в проекте, где обработчики висят на каждой строке, и перепишите на один делегированный обработчик на контейнере. Проверьте, что клики работают после динамической вставки строк. Дальше посмотрите в сторону AbortController — он снимает сразу все обработчики, привязанные с его сигналом, что удобно при размонтировании компонента. Инвалидация и повторная привязка при полной пересборке DOM — отдельная тема, здесь рассматривали только базовую механику событий и делегирование.
Источники: Event — MDN Web Docs, Element.closest() — MDN Web Docs
Частые вопросы
А если браузер старый и closest() не работает?
Делегирование не сработало, клик проходит мимо. В чём дело?
Чем event.target отличается от event.currentTarget?
Как снять обработчик, который добавил анонимной функцией?
Стоит ли вешать один делегированный обработчик на document для всей страницы?
Почему форма перезагружает страницу, хотя я поймал submit?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.