Всплытие и погружение

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

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

Клик по кнопке — событие не только кнопки. Браузер прокладывает маршрут от window до цели и обратно, и почти вся практика работы с событиями держится на этом пути: кто получит событие первым, кто последним и как посмотреть весь список.

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

Когда вы кликаете по кнопке внутри карточки, браузер не зовёт обработчик кнопки напрямую. Сначала он строит путь от window вниз до самой кнопки и проходит его дважды: вниз — это фаза погружения (capturing), затем отмечает цель (target), затем возвращается наверх — фаза всплытия (bubbling).

Обычный addEventListener слушает всплытие. Чтобы попасть в погружение, третьим аргументом передают true или { capture: true }. Проще один раз увидеть порядок, чем запомнить его.

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

Всплытие — вторая половина пути, от цели наверх. Внутри обработчика есть два разных элемента: event.target — тот, на котором событие возникло, и event.currentTarget — тот, на котором висит этот самый обработчик. В обычной (не стрелочной) функции второе значение лежит ещё и в this.

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

Всплытие не заканчивается на ближайшем предке: событие поднимается до document и window, задевая по дороге всех, у кого есть подходящий обработчик. target при этом остаётся неизменным, а currentTarget меняется на каждом шаге.

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

Слушатель с capture: true срабатывает на предке до того, как событие доберётся до цели. Это способ перехватить событие раньше всех: залогировать, отменить, подменить поведение — пока дочерние обработчики ещё не отработали.

Погружение придумал Netscape, всплытие — Internet Explorer. W3C не стал выбирать победителя и склеил обе модели в один маршрут. Отсюда и странный третий аргумент, который годами был просто голым булевым флагом.

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

И важная бытовая деталь: removeEventListener ищет слушатель по трём вещам — тип события, та же самая функция и то же значение capture. Повесили с true, снимаете без него — браузер молча ничего не найдёт.

В объектной форме рядом с capture живут once (снять слушатель после первого срабатывания), passive (обещание не звать preventDefault, важно для скролла и тач-событий) и signal (снять слушатель через AbortController).

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

У каждого события есть флаг bubbles. Если он false, обратного подъёма просто не будет: обработчик на предке, повешенный обычным способом, не сработает никогда — и это одна из самых частых причин тихо не работающего кода.

СобытиеВсплываетЧто делать вместо
focus / blurнетfocusin / focusout
mouseenter / mouseleaveнетmouseover / mouseout
load, error у img и scriptнетслушать сам элемент
scroll на элементенетслушать сам прокручиваемый элемент
click, input, submit, keydownданичего, всё работает

Но есть выход поизящнее списка замен: погружение работает всегда. Флаг bubbles отменяет только подъём — вниз событие проходит в любом случае. Значит, невсплывающее событие всё равно ловится на предке через capture: true.

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

Если непонятно, кто и в каком порядке получит событие, спросите у самого события. composedPath() возвращает массив узлов от цели до window — это и есть весь маршрут. eventPhase говорит, в какой фазе сейчас работает обработчик: 1 — погружение, 2 — цель, 3 — всплытие.

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

stopPropagation() обрывает маршрут: элементы дальше по пути события не увидят. Пользуйтесь осторожно — обработчик, глушащий всплытие, ломает чужой код на предках, включая делегирование и сторонние виджеты. Отлаживать такое неприятно: код просто не вызывается, ошибок нет. Чем остановка оборачивается на живой странице и чем её заменить, разбирает статья «stopPropagation и когда он вреден».

stopPropagation() останавливает движение по дереву, но остальные обработчики на том же элементе всё равно отработают. Чтобы оборвать и их, есть stopImmediatePropagation().

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

  • Событие проходит путь дважды: вниз (погружение), в цели, вверх (всплытие).
  • Обработчик по умолчанию работает на всплытии; третий аргумент true или { capture: true } переводит его в погружение — и снимать такой обработчик нужно с тем же значением capture.
  • target — где событие возникло и не меняется весь путь; currentTarget — элемент, чей обработчик выполняется сейчас.
  • focus, blur, mouseenter, mouseleave, load не всплывают: у части есть всплывающие братья (focusin, focusout), а любое из них ловится на предке в фазе погружения.
  • composedPath() показывает весь маршрут, eventPhase — текущую фазу, stopPropagation() обрывает путь дальше (а stopImmediatePropagation() — ещё и соседние обработчики на том же элементе).

1/3На родителе висят два обработчика click: один обычный, другой с capture: true. Кликнули по кнопке внутри. В каком порядке они сработают?

2/3Обработчик focus повешен на форму обычным способом, фокус получает поле внутри неё. Что произойдёт?

3/3Обработчик висит на элементе panel, клик пришёлся по кнопке внутри него. Чему равен event.currentTarget?

Соберите путь клика

Повесьте один обработчик на card и выведите цепочку id от цели до самой карточки через " -> ". Никаких обработчиков на вложенных элементах.

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

Готовый маршрут уже лежит в событии — нужно только отрезать хвост выше карточки.

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

Без composedPath() — подняться от event.target вверх по parentElement, пока не дойдёте до card:

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

Поймайте то, что не всплывает

Обработчик на форме молчит, потому что focus не всплывает. Почините — двумя разными способами, не трогая разметку.

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

Способ первый — слушать фазу погружения. Вниз событие проходит через форму независимо от флага bubbles.

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

Способ второй — взять всплывающего брата focusin. Настоящий браузер шлёт его от того же элемента, но с bubbles: true, поэтому обработчик на форме сработает обычным образом.

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