Пару недель назад отлаживал баг на проекте. Казалось бы, простая задача — поймать все клики по кнопкам с определённым атрибутом. Повесил обработчик на document через делегирование, запустил… тишина. События не доходят. Полчаса копался в коде, пока не вспомнил про capture-фазу и то, как на самом деле работает распространение событий в DOM.
Давайте разберём эту тему подробно, потому что понимание механики событий сэкономит вам кучу времени на дебаге.
Как браузер обрабатывает клик
Когда пользователь кликает по элементу, браузер не просто вызывает обработчик на этом элементе и забывает. Событие проходит через всё DOM-дерево, и этот путь делится на три стадии.
Представьте структуру:
<body>
<div class="container">
<div class="card">
<button class="close-btn">×</button>
</div>
</div>
</body>
Кликаете по кнопке — и событие начинает своё путешествие.
Фаза 1: Capture (захват, погружение)
Событие стартует от самого корня документа и движется вниз к целевому элементу:
document → html → body → .container → .card → button
Это как будто событие «ныряет» в глубину DOM-дерева, проверяя каждый элемент на пути.
Фаза 2: Target (цель)
Событие достигло элемента, по которому реально кликнули. Тут срабатывают обработчики, которые навешены непосредственно на button.
Фаза 3: Bubble (всплытие)
Теперь событие поднимается обратно наверх:
button → .card → .container → body → html → document
Вот на этой фазе обычно и ловят события большинство разработчиков. И тут могут начаться проблемы.
Почему события не доходят до document
Есть метод event.stopPropagation(). Когда его вызывают в обработчике, событие прекращает своё путешествие. Если вызвали на фазе bubble — событие дальше не всплывёт.
Практический пример. Допустим, у вас есть библиотека для модальных окон (любая, не важно какая). Внутри неё что-то типа:
closeButton.addEventListener('click', (e) => {
e.stopPropagation(); // останавливаем всплытие
closeModal();
});
А вы повесили свой обработчик на document, чтобы логировать клики:
document.addEventListener('click', (e) => {
console.log('Клик по:', e.target);
// Этот код не выполнится для кнопки закрытия!
});
Событие остановится на кнопке и не доберётся до вашего обработчика. Вы будете смотреть в консоль и недоумевать — почему код не работает.
То же самое происходит со многими UI-библиотеками, кастомными компонентами, сторонними виджетами. Они останавливают события для своих внутренних нужд, а ваш код страдает.
Решение: capture-фаза
Фишка в том, что capture идёт до того, как библиотека вообще увидит событие. Вы перехватываете клик на пути вниз, раньше всех.
document.addEventListener('click', (e) => {
console.log('Поймали на capture:', e.target);
}, true); // вот этот true включает capture-фазу
Или более явная форма:
document.addEventListener('click', (e) => {
console.log('Поймали на capture:', e.target);
}, { capture: true });
Теперь даже если библиотека вызовет stopPropagation(), вы уже успели обработать событие.
Реальные примеры использования
1. Глобальная аналитика кликов
Нужно отслеживать все клики по ссылкам и кнопкам для аналитики, но некоторые элементы останавливают всплытие:
document.addEventListener('click', (e) => {
const clickable = e.target.closest('a, button, [role="button"]');
if (clickable) {
const action = clickable.dataset.action;
const label = clickable.textContent.trim();
// отправляем в аналитику
if (typeof gtag !== 'undefined') {
gtag('event', 'click', {
event_category: 'User Interaction',
event_label: label,
element_type: clickable.tagName
});
}
console.log('Клик:', { action, label });
}
}, true);
Работает для всех элементов, даже если они внутри компонентов со stopPropagation.
2. Превентивная блокировка действий
Иногда нужно отменить действие до того, как оно выполнится. Например, предотвратить переход по ссылкам для неавторизованных пользователей:
const user = getCurrentUser();
document.addEventListener('click', (e) => {
const link = e.target.closest('a[data-auth-required]');
if (link && !user.isAuthenticated) {
e.preventDefault();
e.stopPropagation(); // останавливаем здесь, другие обработчики не сработают
showAuthModal({
message: 'Войдите, чтобы продолжить',
returnUrl: link.href
});
}
}, true);
Перехватываем клик на самой ранней стадии и решаем, пускать дальше или нет.
3. Отладка событий
Когда непонятно, почему обработчик не срабатывает, полезно посмотреть все фазы:
// Логируем capture
document.addEventListener('click', (e) => {
console.log('📥 Capture:', e.target.className, 'phase:', e.eventPhase);
}, true);
// Логируем bubble
document.addEventListener('click', (e) => {
console.log('📤 Bubble:', e.target.className, 'phase:', e.eventPhase);
}, false);
// Вывод в консоли покажет весь путь события
e.eventPhase вернёт:
1— CAPTURING_PHASE2— AT_TARGET3— BUBBLING_PHASE
Так сразу видно, на каком этапе событие останавливается.
4. Делегирование для динамического контента
У вас есть список, элементы добавляются динамически через AJAX:
document.addEventListener('click', (e) => {
const deleteBtn = e.target.closest('[data-action="delete"]');
if (deleteBtn) {
const itemId = deleteBtn.dataset.itemId;
const confirmed = confirm('Удалить элемент?');
if (confirmed) {
deleteItem(itemId);
deleteBtn.closest('.list-item').remove();
}
}
}, true);
Один обработчик на весь document работает для всех элементов — существующих и будущих. И даже если в списке используются сторонние компоненты со своими обработчиками.
5. Перехват событий форм
Допустим, нужно валидировать все формы перед отправкой, но в проекте используются разные библиотеки для форм:
document.addEventListener('submit', (e) => {
const form = e.target;
// проверяем обязательные поля вручную
const required = form.querySelectorAll('[data-required]');
let hasErrors = false;
required.forEach(field => {
if (!field.value.trim()) {
field.classList.add('error');
hasErrors = true;
}
});
if (hasErrors) {
e.preventDefault();
e.stopPropagation();
showNotification('Заполните все обязательные поля');
}
}, true);
Ловим submit на capture, проверяем форму раньше всех библиотек.
Когда НЕ нужен capture
Не всегда capture — правильное решение. Если событие нормально всплывает и нет проблем с библиотеками, обычный bubble-обработчик проще и логичнее:
// Обычное делегирование (без capture)
document.addEventListener('click', (e) => {
if (e.target.matches('.toggle-button')) {
e.target.classList.toggle('active');
}
});
Используйте capture только когда:
- События останавливаются библиотеками
- Нужно обработать событие ДО других обработчиков
- Отлаживаете поведение событий
- Требуется превентивная логика
Подводные камни и нюансы
Порядок выполнения
Если несколько capture-обработчиков висят на одном элементе, они выполняются в порядке регистрации:
document.addEventListener('click', () => console.log('Первый'), true);
document.addEventListener('click', () => console.log('Второй'), true);
// Выведет: Первый, Второй
Производительность
Capture-обработчик на document срабатывает при каждом клике на странице. Обязательно добавляйте ранние проверки:
document.addEventListener('click', (e) => {
// Быстро отсекаем ненужные события
if (!e.target.closest('.interactive-area')) return;
// Тяжёлая логика только если нужно
processComplexInteraction(e);
}, true);
Удаление обработчиков
Чтобы снять capture-обработчик, нужно передать те же параметры:
const handler = (e) => { /* ... */ };
// Добавили
document.addEventListener('click', handler, true);
// Удалили (true обязательно!)
document.removeEventListener('click', handler, true);
// Это НЕ сработает (разные фазы):
document.removeEventListener('click', handler); // false по умолчанию
stopImmediatePropagation
Есть ещё более жёсткий метод остановки:
element.addEventListener('click', (e) => {
e.stopImmediatePropagation();
// Остальные обработчики НА ЭТОМ ЭЛЕМЕНТЕ тоже не выполнятся
});
element.addEventListener('click', () => {
console.log('Не выполнится');
});
Даже capture не поможет, если остановка произошла раньше по дереву.
Практическая шпаргалка
// Обычное всплытие (по умолчанию)
element.addEventListener('click', handler);
element.addEventListener('click', handler, false);
element.addEventListener('click', handler, { capture: false });
// Capture-фаза
element.addEventListener('click', handler, true);
element.addEventListener('click', handler, { capture: true });
// Одноразовый обработчик
element.addEventListener('click', handler, { once: true });
// Комбинация опций
element.addEventListener('click', handler, {
capture: true,
once: true,
passive: true // для производительности скролла
});
Визуализация процесса
Вот как это выглядит в коде:
document.body.innerHTML = `
<div id="outer">
<div id="middle">
<button id="inner">Кликни меня</button>
</div>
</div>
`;
const outer = document.getElementById('outer');
const middle = document.getElementById('middle');
const inner = document.getElementById('inner');
// Capture-фаза
outer.addEventListener('click', () => console.log('outer capture'), true);
middle.addEventListener('click', () => console.log('middle capture'), true);
inner.addEventListener('click', () => console.log('inner capture'), true);
// Bubble-фаза
inner.addEventListener('click', () => console.log('inner bubble'));
middle.addEventListener('click', () => console.log('middle bubble'));
outer.addEventListener('click', () => console.log('outer bubble'));
// При клике по кнопке вывод будет:
// outer capture
// middle capture
// inner capture
// inner bubble
// middle bubble
// outer bubble
Событие прошло весь путь туда и обратно.
Итого
Capture-фаза решает проблему с событиями, которые не доходят до ваших обработчиков из-за stopPropagation(). Главное:
- Capture идёт сверху вниз, bubble — снизу вверх
- Третий параметр
trueили{ capture: true }включает capture - Используйте когда события останавливаются библиотеками
- Не злоупотребляйте — для обычных задач bubble проще
- Следите за производительностью при обработчиках на document
В моём случае с аналитикой capture спас ситуацию — один обработчик на весь документ перехватывает все клики раньше любых библиотек. Код работает стабильно, данные собираются полностью. Если бы не знал про эту механику, потратил бы в разы больше времени на костыли.