← назад к разделу

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

Три фазы: capture → target → bubble

Когда пользователь кликает по элементу, браузер создаёт событие и прогоняет его по дереву дважды.

  1. Capture (погружение) — событие идёт сверху вниз: documenthtmlbody → … → целевой элемент. Обработчики в фазе capture вызываются первыми.
  2. Target — событие достигло целевого элемента. Оба типа обработчиков (capture и bubble) вызываются здесь.
  3. Bubble (всплытие) — событие идёт снизу вверх: целевой элемент → … → bodyhtmldocument. Большинство обработчиков слушают именно эту фазу.

Большинство событий всплывают. Исключения: 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 (сверху вниз) → targetbubble (снизу вверх).
  • 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.
  • Обработка ошибок — как ловить ошибки в асинхронных обработчиках событий.