Всплытие и погружение
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Клик по кнопке — событие не только кнопки. Браузер прокладывает маршрут от window до цели и обратно, и почти вся практика работы с событиями держится на этом пути: кто получит событие первым, кто последним и как посмотреть весь список.
Когда вы кликаете по кнопке внутри карточки, браузер не зовёт обработчик кнопки напрямую. Сначала он строит путь от window вниз до самой кнопки и проходит его дважды: вниз — это фаза погружения (capturing), затем отмечает цель (target), затем возвращается наверх — фаза всплытия (bubbling).
Обычный addEventListener слушает всплытие. Чтобы попасть в погружение, третьим аргументом передают true или { capture: true }. Проще один раз увидеть порядок, чем запомнить его.
Всплытие — вторая половина пути, от цели наверх. Внутри обработчика есть два разных элемента: event.target — тот, на котором событие возникло, и event.currentTarget — тот, на котором висит этот самый обработчик. В обычной (не стрелочной) функции второе значение лежит ещё и в this.
Всплытие не заканчивается на ближайшем предке: событие поднимается до document и window, задевая по дороге всех, у кого есть подходящий обработчик. target при этом остаётся неизменным, а currentTarget меняется на каждом шаге.
Слушатель с capture: true срабатывает на предке до того, как событие доберётся до цели. Это способ перехватить событие раньше всех: залогировать, отменить, подменить поведение — пока дочерние обработчики ещё не отработали.
Погружение придумал Netscape, всплытие — Internet Explorer. W3C не стал выбирать победителя и склеил обе модели в один маршрут. Отсюда и странный третий аргумент, который годами был просто голым булевым флагом.
И важная бытовая деталь: removeEventListener ищет слушатель по трём вещам — тип события, та же самая функция и то же значение capture. Повесили с true, снимаете без него — браузер молча ничего не найдёт.
В объектной форме рядом с capture живут once (снять слушатель после первого срабатывания), passive (обещание не звать preventDefault, важно для скролла и тач-событий) и signal (снять слушатель через AbortController).
У каждого события есть флаг bubbles. Если он false, обратного подъёма просто не будет: обработчик на предке, повешенный обычным способом, не сработает никогда — и это одна из самых частых причин тихо не работающего кода.
| Событие | Всплывает | Что делать вместо |
|---|---|---|
| focus / blur | нет | focusin / focusout |
| mouseenter / mouseleave | нет | mouseover / mouseout |
| load, error у img и script | нет | слушать сам элемент |
| scroll на элементе | нет | слушать сам прокручиваемый элемент |
| click, input, submit, keydown | да | ничего, всё работает |
Но есть выход поизящнее списка замен: погружение работает всегда. Флаг bubbles отменяет только подъём — вниз событие проходит в любом случае. Значит, невсплывающее событие всё равно ловится на предке через capture: true.
Если непонятно, кто и в каком порядке получит событие, спросите у самого события. composedPath() возвращает массив узлов от цели до window — это и есть весь маршрут. eventPhase говорит, в какой фазе сейчас работает обработчик: 1 — погружение, 2 — цель, 3 — всплытие.
stopPropagation() обрывает маршрут: элементы дальше по пути события не увидят. Пользуйтесь осторожно — обработчик, глушащий всплытие, ломает чужой код на предках, включая делегирование и сторонние виджеты. Отлаживать такое неприятно: код просто не вызывается, ошибок нет. Чем остановка оборачивается на живой странице и чем её заменить, разбирает статья «stopPropagation и когда он вреден».
stopPropagation() останавливает движение по дереву, но остальные обработчики на том же элементе всё равно отработают. Чтобы оборвать и их, есть stopImmediatePropagation().
- Событие проходит путь дважды: вниз (погружение), в цели, вверх (всплытие).
- Обработчик по умолчанию работает на всплытии; третий аргумент 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 от цели до самой карточки через " -> ". Никаких обработчиков на вложенных элементах.
Готовый маршрут уже лежит в событии — нужно только отрезать хвост выше карточки.
Без composedPath() — подняться от event.target вверх по parentElement, пока не дойдёте до card:
Поймайте то, что не всплывает
Обработчик на форме молчит, потому что focus не всплывает. Почините — двумя разными способами, не трогая разметку.
Способ первый — слушать фазу погружения. Вниз событие проходит через форму независимо от флага bubbles.
Способ второй — взять всплывающего брата focusin. Настоящий браузер шлёт его от того же элемента, но с bubbles: true, поэтому обработчик на форме сработает обычным образом.