stopPropagation и когда он вреден

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

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

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

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

Вызов event.stopPropagation() обрывает путешествие события по цепочке предков. Обработчики выше по дереву — на <div>, на body, на document — просто не будут вызваны. Само событие при этом никуда не исчезает: оно уже сработало там, где его поймали, и действие браузера по умолчанию тоже остаётся в силе.

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

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

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

Если нужно заткнуть и соседей, есть более тяжёлый инструмент — event.stopImmediatePropagation(). Он делает всё то же, что stopPropagation(), и вдобавок отменяет остальные обработчики этого же события на этом же элементе — те, что подписались после текущего.

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

Обратите внимание, насколько результат зависит от порядка подписки: подключись «второй» раньше — и он бы успел отработать. Поведение кода начинает зависеть от того, в каком порядке загрузились скрипты. Это плохой фундамент, и потому stopImmediatePropagation() — почти всегда признак того, что архитектура где-то не сложилась.

ВызовЧто делаетЧего НЕ делает
preventDefault()Отменяет действие браузера по умолчанию: переход по ссылке, отправку формы, прокруткуНе мешает событию всплывать — все обработчики выше отработают
stopPropagation()Не пускает событие дальше по цепочке предковНе отменяет действие браузера и не трогает другие обработчики того же элемента
stopImmediatePropagation()То же плюс отменяет оставшиеся обработчики на этом элементеНе отменяет действие браузера
return false в HTML-атрибуте onclickРаботает как preventDefault()Ничего не останавливает во всплытии; внутри addEventListener не значит вообще ничего

Всплытие — не деталь реализации, а публичный интерфейс страницы. На нём держится делегирование: один обработчик на списке обслуживает все элементы, включая те, которых ещё нет в DOM. Одна остановка внутри — и делегирование про эту кнопку не узнает никогда.

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

На document обычно висит целая толпа слушателей, которых вы не писали: счётчик аналитики, закрытие выпадающего меню по клику снаружи, подсветка активной вкладки, хоткеи. Все они молча перестают работать в том углу страницы, где кто-то однажды поставил stopPropagation().

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

Худшее в таком баге — расстояние между причиной и симптомом. Жалоба звучит как «аналитика не считает клики в модалке» или «меню не закрывается только на странице заказа», а виновная строка лежит в другом файле, написана полгода назад и выглядит совершенно безобидно. С React то же самое: он слушает клики на корне приложения, и остановка между кнопкой и корнем молча оставит без события все его обработчики — это разбирает статья «Всплытие, отмена и остановка в React».

Первый вопрос: а точно ли вы хотели остановить событие? Чаще всего хотели отменить действие браузера — переход по ссылке или отправку формы. Это preventDefault(), и он не мешает событию идти дальше.

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

Второй приём — исправлять там, где мешает. Классика: «клик вне меню закрывает меню», и чтобы кнопка-переключатель не закрывала его сама, на неё вешают stopPropagation(). Но условие «этот клик — внутри меню» принадлежит обработчику на document, а не кнопке. Проверка через contains() или closest() решает задачу, никого не ограбив.

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

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

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

Запрета нет — есть цена. Остановка честна там, где вы владеете всем маршрутом: собственное событие (new CustomEvent("widget:pick")), которое вы сами породили и сами слушаете, изолированный компонент внутри Shadow DOM, отладочная заглушка на пять минут. Общее правило: глушить можно то, что придумали вы, и в пределах того дерева, за которое отвечаете вы.

Всё остальное — click, keydown, focusin, submit на публичной странице — общий ресурс. Библиотека, аналитика, ассистивные технологии и ваш собственный код из соседней команды рассчитывают, что события доходят до document.

Перед каждым stopPropagation спросите себя: что именно я хочу отменить? Если действие браузера — нужен preventDefault. Если чужую реакцию — договоритесь с этим кодом, а не глушите событие для всей страницы.

Найти источник проще всего, временно подменив сам метод: любой вызов оставит след. В настоящей отладке вместо массива пишут console.trace() — он покажет стек и файл, где стоит остановка.

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

А если исправить чужой виджет нельзя, есть надёжный обход: слушайте в фазе захвата. Захват идёт сверху вниз, от document к цели, и до элемента-виновника ваш обработчик уже отработал. Именно так защищаются серьёзные счётчики аналитики.

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

  • stopPropagation() обрывает путь события к предкам, но не мешает другим обработчикам того же элемента — их отменяет только stopImmediatePropagation().
  • Событие, остановленное на кнопке, не увидят ни делегирование выше, ни аналитика на document, ни «закрытие по клику снаружи» — а баг всплывёт далеко от причины.
  • Отмена действия браузера — это preventDefault(); путать её с остановкой всплытия дорого.
  • Вместо остановки решайте вопрос там, где он мешает: contains()/closest() во внешнем обработчике или флаг на объекте события.
  • Обработчик в фазе захвата на document увидит событие даже тогда, когда цель его глушит — так спасают аналитику в чужом коде.

1/3На кнопку повесили два обработчика click. Первый вызывает event.stopPropagation(). Что будет со вторым?

2/3Аналитика слушает click на document в обычной фазе всплытия. Виджет вызывает stopPropagation на своей кнопке. Что увидит аналитика?

3/3Внутри карточки есть ссылка. Нужно, чтобы браузер по ней не переходил, но клик по-прежнему считался аналитикой на document.

Уберите остановку, сохранив поведение

Клик по карточке открывает её, клик по кнопке «В избранное» — только добавляет в избранное. Автор решил задачу через stopPropagation(), и теперь аналитика на document не видит этот клик. Перепишите обработчики так, чтобы карточка не открывалась, а аналитика сработала.

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

Условие «клик пришёл из кнопки» принадлежит обработчику карточки — там его и проверяем. Подойдёт и флаг на событии, и closest().

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

Вариант без флага — проверка цели: if (event.target.closest("#fav")) return; в обработчике карточки. Оба решения оставляют событие живым, и всё, что висит выше, продолжает работать.

Услышьте клик, который глушат

Виджет чужой, править его нельзя: он вызывает stopPropagation(). Ваша задача — досчитать клик, изменив только свою строку подписки.

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

Подпишитесь в фазе захвата — третьим аргументом true или объектом { capture: true }.

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

Захват идёт сверху вниз, от document к цели, поэтому ваш обработчик отрабатывает до кнопки. Остановка на цели наступает уже после и на него не влияет.