stopPropagation и когда он вреден
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
stopPropagation() выглядит как аккуратная точка: обработчик отработал, дальше событию идти незачем. На деле это выстрел по всей странице — вы отменяете не своё поведение, а чужие обработчики, о которых ничего не знаете.
Если нужно заткнуть и соседей, есть более тяжёлый инструмент — event.stopImmediatePropagation(). Он делает всё то же, что stopPropagation(), и вдобавок отменяет остальные обработчики этого же события на этом же элементе — те, что подписались после текущего.
Обратите внимание, насколько результат зависит от порядка подписки: подключись «второй» раньше — и он бы успел отработать. Поведение кода начинает зависеть от того, в каком порядке загрузились скрипты. Это плохой фундамент, и потому stopImmediatePropagation() — почти всегда признак того, что архитектура где-то не сложилась.
| Вызов | Что делает | Чего НЕ делает |
|---|---|---|
| preventDefault() | Отменяет действие браузера по умолчанию: переход по ссылке, отправку формы, прокрутку | Не мешает событию всплывать — все обработчики выше отработают |
| stopPropagation() | Не пускает событие дальше по цепочке предков | Не отменяет действие браузера и не трогает другие обработчики того же элемента |
| stopImmediatePropagation() | То же плюс отменяет оставшиеся обработчики на этом элементе | Не отменяет действие браузера |
| return false в HTML-атрибуте onclick | Работает как preventDefault() | Ничего не останавливает во всплытии; внутри addEventListener не значит вообще ничего |
Всплытие — не деталь реализации, а публичный интерфейс страницы. На нём держится делегирование: один обработчик на списке обслуживает все элементы, включая те, которых ещё нет в DOM. Одна остановка внутри — и делегирование про эту кнопку не узнает никогда.
На document обычно висит целая толпа слушателей, которых вы не писали: счётчик аналитики, закрытие выпадающего меню по клику снаружи, подсветка активной вкладки, хоткеи. Все они молча перестают работать в том углу страницы, где кто-то однажды поставил stopPropagation().
Худшее в таком баге — расстояние между причиной и симптомом. Жалоба звучит как «аналитика не считает клики в модалке» или «меню не закрывается только на странице заказа», а виновная строка лежит в другом файле, написана полгода назад и выглядит совершенно безобидно. С React то же самое: он слушает клики на корне приложения, и остановка между кнопкой и корнем молча оставит без события все его обработчики — это разбирает статья «Всплытие, отмена и остановка в React».
Первый вопрос: а точно ли вы хотели остановить событие? Чаще всего хотели отменить действие браузера — переход по ссылке или отправку формы. Это preventDefault(), и он не мешает событию идти дальше.
Второй приём — исправлять там, где мешает. Классика: «клик вне меню закрывает меню», и чтобы кнопка-переключатель не закрывала его сама, на неё вешают stopPropagation(). Но условие «этот клик — внутри меню» принадлежит обработчику на document, а не кнопке. Проверка через contains() или closest() решает задачу, никого не ограбив.
Когда внешний и внутренний обработчики — оба ваши, договоритесь через флаг на объекте события. Событие продолжает всплывать, аналитика и делегирование его видят, а ваш внешний обработчик знает, что клик уже обработан.
Запрета нет — есть цена. Остановка честна там, где вы владеете всем маршрутом: собственное событие (new CustomEvent("widget:pick")), которое вы сами породили и сами слушаете, изолированный компонент внутри Shadow DOM, отладочная заглушка на пять минут. Общее правило: глушить можно то, что придумали вы, и в пределах того дерева, за которое отвечаете вы.
Всё остальное — click, keydown, focusin, submit на публичной странице — общий ресурс. Библиотека, аналитика, ассистивные технологии и ваш собственный код из соседней команды рассчитывают, что события доходят до document.
Перед каждым stopPropagation спросите себя: что именно я хочу отменить? Если действие браузера — нужен preventDefault. Если чужую реакцию — договоритесь с этим кодом, а не глушите событие для всей страницы.
Найти источник проще всего, временно подменив сам метод: любой вызов оставит след. В настоящей отладке вместо массива пишут console.trace() — он покажет стек и файл, где стоит остановка.
А если исправить чужой виджет нельзя, есть надёжный обход: слушайте в фазе захвата. Захват идёт сверху вниз, от document к цели, и до элемента-виновника ваш обработчик уже отработал. Именно так защищаются серьёзные счётчики аналитики.
- stopPropagation() обрывает путь события к предкам, но не мешает другим обработчикам того же элемента — их отменяет только stopImmediatePropagation().
- Событие, остановленное на кнопке, не увидят ни делегирование выше, ни аналитика на document, ни «закрытие по клику снаружи» — а баг всплывёт далеко от причины.
- Отмена действия браузера — это preventDefault(); путать её с остановкой всплытия дорого.
- Вместо остановки решайте вопрос там, где он мешает: contains()/closest() во внешнем обработчике или флаг на объекте события.
- Обработчик в фазе захвата на document увидит событие даже тогда, когда цель его глушит — так спасают аналитику в чужом коде.
1/3На кнопку повесили два обработчика click. Первый вызывает event.stopPropagation(). Что будет со вторым?
2/3Аналитика слушает click на document в обычной фазе всплытия. Виджет вызывает stopPropagation на своей кнопке. Что увидит аналитика?
3/3Внутри карточки есть ссылка. Нужно, чтобы браузер по ней не переходил, но клик по-прежнему считался аналитикой на document.
Уберите остановку, сохранив поведение
Клик по карточке открывает её, клик по кнопке «В избранное» — только добавляет в избранное. Автор решил задачу через stopPropagation(), и теперь аналитика на document не видит этот клик. Перепишите обработчики так, чтобы карточка не открывалась, а аналитика сработала.
Условие «клик пришёл из кнопки» принадлежит обработчику карточки — там его и проверяем. Подойдёт и флаг на событии, и closest().
Вариант без флага — проверка цели: if (event.target.closest("#fav")) return; в обработчике карточки. Оба решения оставляют событие живым, и всё, что висит выше, продолжает работать.
Услышьте клик, который глушат
Виджет чужой, править его нельзя: он вызывает stopPropagation(). Ваша задача — досчитать клик, изменив только свою строку подписки.
Подпишитесь в фазе захвата — третьим аргументом true или объектом { capture: true }.
Захват идёт сверху вниз, от document к цели, поэтому ваш обработчик отрабатывает до кнопки. Остановка на цели наступает уже после и на него не влияет.