Каталог отрисовал новую страницу товаров через Ajax: #products получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться. Цена ошибки — один пользовательский клик создаёт несколько запросов.
Главный вопрос этой заметки: почему обработчик пропадает после .html() и как выбрать делегирование так, чтобы оно пережило замену карточек? Здесь важно не запомнить «вешай всё на document», а увидеть, на каком DOM-узле реально хранится обработчик и какой узел переживает обновление.
Что делает .html() с прежней разметкой
Когда .html(строка) задаёт новое содержимое, jQuery полностью заменяет прежних потомков контейнера. Документация отдельно предупреждает: перед заменой jQuery удаляет из дочерних элементов данные и обработчики событий. Поэтому прямой click на старой кнопке не «ломается» — он остаётся на старом DOM-узле, которого больше нет. Новая кнопка похожа внешне, но для браузера это другой объект.
В этом легко убедиться на коротком примере. Сначала обработчик привязан непосредственно к найденной кнопке. После замены HTML в контейнере новая кнопка появляется без этого обработчика.
var $products = $('#products');
function buy(event) {
event.preventDefault();
window.console.log('Товар добавлен');
}
$products.find('.js-buy').on('click', buy);
$products.html('<a class="js-buy" href="/cart/add/17">Купить</a>');
// Эта новая ссылка создана после .on(), поэтому buy для неё не назначен.
$products.find('.js-buy').trigger('click');
Это не повод каждый раз обходить все кнопки после рендера. Прямая привязка нормальна, когда элемент стабилен и событие относится только к нему. Но в списке, который полностью перерисовывается, она делает жизненный цикл события зависимым от каждой вставки HTML. Такую зависимость лучше перенести на постоянный контейнер.
Прямая привязка и делегирование — это разные владельцы
У .on() без селектора обработчик привязан к текущему набору элементов. Если передать селектор вторым аргументом, обработчик остаётся на выбранном предке и вызывается, когда событие всплывает от подходящего потомка. Документация jQuery называет эти варианты direct и delegated. Для нашего каталога владелец события должен быть не карточкой, а #products, если этот блок не заменяется целиком.
| Подход | Где хранится обработчик | Что случится после .html() | Подходит для |
|---|---|---|---|
Прямой $(".js-buy").on(...) | На найденных кнопках | Старые узлы удалены, новым кнопкам нужен новый bind | Стабильная одиночная кнопка или плагин, которому нужен именно элемент |
Делегированный $root.on(..., ".js-buy", ...) | На постоянном $root | Новая кнопка под тем же корнем начинает работать сразу | Карточки, строки таблицы, пункты меню, которые заменяются |
На document | На самом верхнем доступном узле | Технически может пережить почти любую замену | Только когда ближнего постоянного контейнера действительно нет |
Исправление на устойчивом контейнере
Выберем ближайший узел, который существует до и после обновления списка. Здесь это #products. Перед назначением снимем только своё пространство имён: так повторная инициализация не будет плодить обработчики, а соседние click-события останутся на месте.
(function ($) {
function addToCart(event) {
event.preventDefault();
var $link = $(this);
var productId = $link.data('product-id');
if (!productId) {
window.console.warn('У кнопки нет product-id');
return;
}
window.console.log('Добавляем товар ' + productId);
}
function mountProductList(root) {
var $root = $(root);
$root.off('.productList');
$root.on('click.productList', '.js-buy', addToCart);
}
window.mountProductList = mountProductList;
}(jQuery));
mountProductList('#products');
Теперь серверный ответ может заменить внутренности #products, а обработчик остаётся на самом контейнере. Он увидит клик, который всплывёт от новой ссылки и совпадёт с селектором .js-buy. Если проект меняет и сам #products, этот код не сделает чудо: нужно вызвать mountProductList для нового контейнера или выбрать более внешний, но всё ещё локальный корень.
Проверяем разметку и событие по отдельности
В legacy-проекте легко перепутать три причины: Ajax вернул не ту разметку, селектор не совпал или событие не дошло до корня. Поэтому я бы проверял их раздельно. Сначала подменяю HTML статической строкой, затем запускаю программный click, и только после этого возвращаю реальный запрос. Так сетевой сбой не маскирует ошибку жизненного цикла DOM.
var calls = 0;
var $root = $('<div id="products"><a class="js-buy" data-product-id="17" href="#">Купить</a></div>');
$('body').append($root);
$root.off('.demo');
$root.on('click.demo', '.js-buy', function (event) {
event.preventDefault();
calls += 1;
});
$root.html('<a class="js-buy" data-product-id="18" href="#">Купить другую</a>');
$root.find('.js-buy').trigger('click');
window.console.assert(calls === 1, 'Делегированный click должен дойти до корня');
$root.remove();
Если проверка не проходит, сначала смотрим на корень: он существует в момент вызова .on(), внутри него действительно лежит новая кнопка, и её класс совпадает с селектором? Затем проверяем тип события. В документации jQuery есть важные исключения: делегированные обработчики не работают для SVG, а некоторые события не всплывают. Для таких случаев нельзя механически переносить click-шаблон.
Почему document — не первая точка
У document есть соблазнительное свойство: он почти всегда живёт дольше виджета. Но документация jQuery советует выбирать место как можно ближе к целевым элементам. На большой странице делегирование высокочастотных событий сверху заставляет jQuery сравнивать селекторы по длинному пути всплытия. Для click на небольшом участке разница может быть незаметна, но архитектурно всё равно лучше, когда каталог слушает каталог, а не весь сайт.
Есть и практическая причина. Локальный корень показывает границу ответственности: код карточек не должен случайно перехватить похожую кнопку в модальном окне или в шапке. Селектор .js-buy становится понятным только в контексте #products.
Отдельный риск: строка HTML — это не безопасные данные
У .html() есть ещё один неприятный край. Документация jQuery предупреждает, что методы, принимающие HTML-строку, потенциально выполняют код из вставленных тегов или атрибутов. Поэтому в пример выше строка попала только как тестовая разметка, написанная в исходнике. Нельзя передавать в .html() необработанный параметр URL, текст из формы или поле API, если сервер не гарантирует его безопасное формирование.
Порядок исправления
- Найти точный вызов
.html()или другой код, который заменяет дочерние карточки. - Проверить, какой ближайший контейнер не заменяется при обновлении.
- Снять со стабильного контейнера только события конкретного виджета по пространству имён.
- Назначить делегированный обработчик с простым селектором потомка.
- Подменить разметку тестовой строкой и вызвать click программно, чтобы отделить DOM-проблему от сети.
- Вернуть реальный Ajax и отдельно проверить, что HTML приходит из доверенного источника и соответствует ожидаемому контракту.
Ограничения
- Делегирование не заменяет прямую привязку во всех случаях. Если нужен обработчик на самом элементе плагина или событие не всплывает, придётся выбрать другой контракт.
- По документации jQuery делегированные обработчики не работают для SVG. Для интерактивных SVG нельзя рассчитывать на этот пример без отдельной проверки.
- Слишком общий корень и тяжёлый селектор могут создать лишнюю работу при частых событиях. Выбираем ближайший живой контейнер и простую границу.
- Починка click не решает вопрос повторной серверной операции. Контракт формы и запросов нужно проверять отдельно.
Итог
После .html() новая кнопка — это новый DOM-узел без старого прямого обработчика. Делегирование решает ровно эту задачу, если обработчик живёт на устойчивом и близком контейнере. Когда мы называем владельца события и проверяем замену разметки отдельно от Ajax, исчезает и необходимость в случайных повторных bind.
Проверяемые источники
- jQuery API: .html() — замена содержимого, удаление событий дочерних узлов и риск вставки непроверенной HTML-строки
- jQuery API: .on() — прямая и делегированная привязка, пространства имён, повторная привязка и ограничения делегирования
- jQuery API: .off() — снятие обработчика по типу события, селектору и пространству имён
- jQuery API: .data() — хранение состояния рядом с DOM-узлом