Клик на кнопке внутри списка — казалось бы, простая вещь. Но браузер не просто «стреляет» событие на элементе: оно проходит сквозь всё дерево DOM в три фазы. Понимание этого механизма позволяет писать обработчики эффективно, избегать лишних слушателей и не удивляться, когда stopPropagation ломает чужой код.
Три фазы: capture → target → bubble
Когда пользователь кликает по элементу, браузер создаёт событие и прогоняет его по дереву дважды.
- Capture (погружение) — событие идёт сверху вниз:
document→html→body→ … → целевой элемент. Обработчики в фазе capture вызываются первыми. - Target — событие достигло целевого элемента. Оба типа обработчиков (capture и bubble) вызываются здесь.
- Bubble (всплытие) — событие идёт снизу вверх: целевой элемент → … →
body→html→document. Большинство обработчиков слушают именно эту фазу.
Большинство событий всплывают. Исключения: focus, blur, scroll, load — они не всплывают (есть аналоги focusin/focusout, которые всплывают).
addEventListener и опции
element.addEventListener("click", handler); // bubble-фаза (по умолчанию)
element.addEventListener("click", handler, true); // capture-фаза
element.addEventListener("click", handler, {
capture: false, // bubble
once: true, // сработает один раз, затем удалится
passive: true, // не вызовет preventDefault — браузер не ждёт
});
once: true — удобная замена ручному removeEventListener для одноразовых обработчиков.
event.target и event.currentTarget
event.target— элемент, на котором произошло событие (по которому кликнул пользователь).event.currentTarget— элемент, на котором установлен обработчик.
document.querySelector("ul").addEventListener("click", (e) => {
console.log(e.target); // <li> или <button> — куда кликнули
console.log(e.currentTarget); // <ul> — где висит обработчик
});
При всплытии currentTarget меняется (каждый обработчик на пути получает «себя»), а target остаётся неизменным — это исходный элемент.
stopPropagation и preventDefault
event.stopPropagation() — останавливает распространение события: ни вверх по дереву, ни вниз дальше не идёт. Используйте с осторожностью: вышестоящий код может рассчитывать на то, что событие до него дойдёт. Модальные окна, аналитика, библиотеки компонентов часто слушают события на уровне document.
button.addEventListener("click", (e) => {
e.stopPropagation(); // клик не всплывёт к родителям
doSomething();
});
event.preventDefault() — отменяет действие браузера по умолчанию (переход по ссылке, отправка формы, скролл при колёсике). Не останавливает распространение.
link.addEventListener("click", (e) => {
e.preventDefault(); // браузер не перейдёт по href
handleNavigation(e.currentTarget.href);
});
stopPropagation нужен редко — чаще достаточно preventDefault. Бесконтрольный stopPropagation — популярный источник трудноуловимых багов в сложных интерфейсах.
Делегирование: один обработчик на контейнер
Вместо того чтобы вешать обработчик на каждый дочерний элемент — вешают один на родителя и внутри смотрят на event.target.
Без делегирования — обработчик на каждой кнопке:
document.querySelectorAll(".item button").forEach((btn) => {
btn.addEventListener("click", handleDelete);
});
Проблема: при динамическом добавлении новых элементов придётся вешать обработчики заново.
С делегированием — один обработчик на список:
document.querySelector(".list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-action='delete']");
if (!btn) return;
const id = btn.closest(".item").dataset.id;
handleDelete(id);
});
Element.closest(selector) идёт вверх по дереву и находит ближайшего предка (или сам элемент), соответствующего селектору. Если клик был по иконке внутри кнопки — e.target попадёт на иконку, а closest("[data-action]") найдёт саму кнопку.
Делегирование даёт два преимущества: меньше слушателей в памяти и автоматическая поддержка динамически добавленных элементов.
passive и производительность скролла
Браузер не знает заранее, вызовет ли обработчик preventDefault() на touchstart или wheel. Пока обработчик не завершится — браузер вынужден ждать, прежде чем начать скролл. Это создаёт задержку даже при пустом обработчике.
passive: true сообщает браузеру: «я не вызову preventDefault» — браузер сразу начинает скролл параллельно с вашим кодом.
document.addEventListener("touchstart", onTouch, { passive: true });
document.addEventListener("wheel", onWheel, { passive: true });
Если пометить обработчик passive: true и вызвать preventDefault() — браузер проигнорирует вызов и выдаст предупреждение в консоли.
Custom Events
Иногда нужно передать событие между несвязанными частями приложения без пробрасывания коллбэков вниз через пропсы.
// Создание и диспатч
const event = new CustomEvent("user:logout", {
bubbles: true, // всплывает по дереву
composed: true, // переходит через shadow DOM
detail: { userId: 42 },
});
document.dispatchEvent(event);
// Подписка
document.addEventListener("user:logout", (e) => {
console.log("Вышел пользователь:", e.detail.userId);
});
bubbles: true позволяет слушать событие на любом предке — это то же делегирование, но для кастомных событий.
Как это работает в React
React не вешает обработчики напрямую на DOM-элементы. Начиная с React 17, все обработчики регистрируются на корневом контейнере (root). Это собственное делегирование React.
<button onClick={handleClick}>Нажать</button>
// React вешает обработчик на root, не на <button>
Синтетические события. React оборачивает нативное событие в SyntheticEvent — объект с тем же интерфейсом, но кросс-браузерный. До React 17 события объединялись в пул и обнулялись после обработчика (поэтому нельзя было хранить event в setTimeout). С React 17 пулинг убран — event живёт столько, сколько нужно.
e.stopPropagation() в React останавливает всплытие в дереве React-компонентов. Нативный addEventListener на том же элементе может сработать раньше или позже в зависимости от того, на каком уровне зарегистрирован — это нужно учитывать при смешивании React и нативных обработчиков.
Коротко
- Событие проходит три фазы: capture (сверху вниз) → target → bubble (снизу вверх).
addEventListenerпо умолчанию слушает bubble-фазу;{ capture: true }— capture-фазу.event.target— элемент-источник;event.currentTarget— элемент с обработчиком.stopPropagationостанавливает распространение; применять осторожно — ломает чужих слушателей.preventDefaultотменяет действие браузера, не останавливает распространение.- Делегирование: один обработчик на контейнер +
closest()— поддерживает динамические элементы и экономит память. passive: trueнаtouchstart/wheelубирает задержку скролла.- React 17+ регистрирует обработчики на root-контейнере — собственный вариант делегирования.
Что почитать дальше
- this: контекст вызова — почему this теряется в обработчиках и как фиксировать.
- Event loop: как работает и где стреляет — как события попадают в очередь задач.
- Производительность рендеринга — passive-слушатели и их влияние на Core Web Vitals.
- Обработка ошибок — как ловить ошибки в асинхронных обработчиках событий.