
Всплытие событий JavaScript: как найти причину двойной обработки клика


Если клик по кнопке внутри карточки внезапно запускает ещё и действие самой карточки, проблема часто не в «сломанных» слушателях и не в браузере. Обычно это обычное всплытие события: пользователь нажал на вложенный элемент, а событие дошло до родителя, у которого тоже есть обработчик. На странице это выглядит как двойная обработка одного клика.
Ниже разберём один конкретный сценарий: список карточек товаров, где клик по кнопке «Купить» внутри карточки неожиданно ещё и открывает карточку целиком. Это удобно для отладки, потому что ошибка воспроизводится стабильно, а исправление потом можно перенести и на другие интерфейсы со списками, плитками и меню.
Почему один клик превращается в два действия
Карточка почти всегда состоит из вложенных элементов: список, элемент списка, заголовок, кнопка, иконка внутри кнопки. Для браузера это дерево. Когда пользователь нажимает на вложенную кнопку, событие возникает на самом глубоком элементе, а затем поднимается выше по дереву. Поэтому код на кнопке и код на карточке могут сработать подряд, хотя человек сделал только одно действие.
Из-за этого легко начать искать не там. Разработчик видит два эффекта и делает вывод, что один и тот же обработчик навешан дважды. Такое тоже бывает, но в списках карточек чаще причина другая: один обработчик стоит на кнопке, второй — на родителе или даже на всём контейнере списка через делегирование. Пока вы не проверили, какой именно элемент получил событие первым и какой обрабатывает его сейчас, чинить код рано.
Полезная привычка такая: сначала нарисовать для себя короткую цепочку список → карточка → кнопка → вложенный span, а уже потом смотреть на слушатели. Это быстрее, чем сразу переставлять addEventListener по файлу и гадать, почему поведение не меняется.
Учебный пример: воспроизводимая ошибка в списке карточек
УЧЕБНЫЙ ПРИМЕР
Представим список карточек. По клику на карточку мы хотим открыть подробности товара. По клику на кнопку внутри карточки — добавить товар в корзину. Ошибка в том, что после нажатия на кнопку срабатывают оба сценария.
<ul class="cards" id="cards">
<li class="card" data-id="101">
<h3>Наушники</h3>
<button class="card__buy" type="button">
<span>Купить</span>
</button>
</li>
<li class="card" data-id="102">
<h3>Колонка</h3>
<button class="card__buy" type="button">
<span>Купить</span>
</button>
</li>
</ul>const cards = document.querySelector('#cards');
const buyButtons = document.querySelectorAll('.card__buy');
let openCount = 0;
let addCount = 0;
function openCard(id) {
openCount += 1;
console.log('openCard', id, 'openCount=', openCount);
}
function addToCart(id) {
addCount += 1;
console.log('addToCart', id, 'addCount=', addCount);
}
cards.addEventListener('click', (event) => {
const card = event.target.closest('.card');
if (!card) return;
openCard(card.dataset.id);
});
buyButtons.forEach((button) => {
button.addEventListener('click', (event) => {
const card = event.currentTarget.closest('.card');
addToCart(card.dataset.id);
});
});Что получится на практике. Если три раза нажать кнопку «Купить» в карточке с data-id="101", счётчик addCount станет равен 3 — это ожидаемо. Но openCount тоже станет равен 3, хотя карточку мы открывать не собирались. Это уже проверяемый симптом: один пользовательский клик дошёл до двух обработчиков на разных уровнях.
Здесь полезно заметить ещё одну деталь. У нас текст кнопки лежит внутри span. Значит, исходной целью клика может быть не сама кнопка, а этот вложенный элемент. Из-за этого код, который смотрит только на event.target без closest(), нередко ломается на совершенно обычной разметке.
Как доказать причину через target и currentTarget
Чтобы не спорить с интерфейсом на уровне ощущений, добавьте в оба обработчика короткий журнал. Он должен показывать не только тип действия, но и два свойства события: target и currentTarget. Первое отвечает на вопрос «где клик возник», второе — «чей обработчик сейчас выполняется».
cards.addEventListener('click', (event) => {
console.log('LIST', {
target: event.target.className,
currentTarget: event.currentTarget.id
});
});
buyButtons.forEach((button) => {
button.addEventListener('click', (event) => {
console.log('BUTTON', {
target: event.target.className,
currentTarget: event.currentTarget.className
});
});
});При клике по надписи внутри кнопки вы обычно увидите картину вроде такой: сначала срабатывает обработчик кнопки, где currentTarget — это card__buy, затем обработчик списка, где currentTarget — это cards. А вот target в обоих сообщениях может оказаться одним и тем же вложенным span. Это и есть прямое доказательство: событие не дублируется, а поднимается вверх по дереву.
| Что смотреть | Что показывает | Как читать в нашем примере |
|---|---|---|
event.target | Исходный элемент клика | Может быть span внутри кнопки |
event.currentTarget | Элемент, чей обработчик сейчас выполняется | Сначала кнопка, потом список |
closest('.card') | Ближайшую карточку выше по дереву | Позволяет стабильно получить data-id |
После такого лога обычно становится ясно, что именно чинить. Если два действия должны жить независимо, нужно остановить всплытие в дочернем обработчике. Если же вся логика списка должна быть в одном месте, лучше убрать разрозненные слушатели и перейти на единое делегирование.
Где делегирование помогает, а где создаёт скрытую вторую реакцию
Делегирование удобно в списках, которые часто меняются. Вместо десятков отдельных слушателей на карточках или кнопках вы ставите один обработчик на контейнер и внутри решаете, по чему именно нажал пользователь. Это особенно полезно, когда карточки подгружаются динамически: новый элемент начнёт работать без дополнительного кода навешивания.
Проблема возникает в момент частичного рефакторинга. Например, раньше у каждой кнопки был свой обработчик, а потом вы добавили делегирование на список, но старые слушатели не удалили. Тогда в кодовой базе появляются два центра принятия решения: дочерний элемент уже делает действие сам, а родитель ещё и пытается «додумать», что произошло. Пользователь видит лишнее открытие, двойной лог, повторную анимацию или повторный запрос.
У такого бага есть два нормальных способа исправления. Первый — локальный: в обработчике кнопки вызвать stopPropagation(), если родитель не должен реагировать на этот клик вообще. Второй — архитектурный: перенести логику в один делегированный обработчик контейнера и явно разрулить ветки кнопка и карточка. Для списков карточек второй вариант обычно проще проверять и сопровождать, потому что путь одного клика описан в одном месте.
Как сделать так, чтобы на один клик было одно действие
Для нашего примера надёжнее оставить один обработчик на списке и сначала проверять, не попал ли клик в кнопку покупки. Если попал, выполнить только добавление в корзину и сразу выйти из функции. Если нет — попробовать открыть карточку. Тогда одно пользовательское действие пойдёт только по одной ветке.
const cards = document.querySelector('#cards');
let openCount = 0;
let addCount = 0;
function openCard(id) {
openCount += 1;
console.log('openCard', id, 'openCount=', openCount);
}
function addToCart(id) {
addCount += 1;
console.log('addToCart', id, 'addCount=', addCount);
}
cards.addEventListener('click', (event) => {
const buyButton = event.target.closest('.card__buy');
if (buyButton) {
const card = buyButton.closest('.card');
if (!card) return;
addToCart(card.dataset.id);
return;
}
const card = event.target.closest('.card');
if (!card || !cards.contains(card)) return;
openCard(card.dataset.id);
});Теперь результат можно проверить без догадок. Один клик по кнопке меняет только addCount. Один клик по свободной области карточки меняет только openCount. Клик мимо карточек не делает ничего. Этот же код корректно переживает вложенный span внутри кнопки и добавление новых карточек в список после загрузки страницы.
Если же вам принципиально нужны отдельные обработчики на дочерних элементах, добавьте в кнопку event.stopPropagation(). Это рабочий вариант, но его стоит использовать осознанно: он прячет событие от родителей, а значит, при дальнейшей доработке кто-то может не понять, почему контейнер «не видит» часть кликов. Поэтому для больших списков полезно сначала спросить себя: можно ли свести маршрут клика к одному месту и одному решению.
Практический вывод такой: при двойной обработке клика сначала проверьте дерево элементов, затем выведите target и currentTarget, и только после этого выбирайте способ контроля — ранний return в делегированном обработчике или остановку всплытия. В большинстве карточных интерфейсов этого достаточно, чтобы быстро локализовать причину и превратить один клик обратно в одно действие.
Если хотите добрать тему системно, на официальной странице курса «JavaScript» указаны блоки «Введение в DOM» и «Расширенный DOM»: в программе заявлены работа с HTML-элементами, перемещение по DOM-дереву и события в браузере. Для задачи со всплытием полезно смотреть именно эти части программы, а затем проверять понимание на собственном маленьком интерфейсе со списком карточек.





















