{"index":347,"slug":"editorial-2018-05-mechanism-legacy-jquery","title":"jQuery. Почему кнопка перестаёт работать после .html()","excerpt":"После обновления каталога через Ajax карточки видны, но кнопка больше не реагирует. Разбираем, почему .html() удаляет обработчики дочерних узлов, как выбрать живой контейнер для делегирования и как проверить решение без дублирования кликов.","contentHtml":"
Каталог обновляет список товаров через Ajax. На экране появляются новые карточки, но кнопка «В корзину» не реагирует. Если повторно вызвать функцию инициализации, новые кнопки начинают работать. Затем старые и новые кнопки иногда отправляют по два запроса. Цена ошибки — один клик меняет состояние заказа несколько раз, а поиск причины превращается в проверку случайного порядка Ajax, рендера и привязки событий.
\nПроблема возникает не потому, что jQuery «забывает» обработчик. Код привязывает click к старому DOM-узлу, а .html() удаляет этот узел и создаёт новый. Решение состоит из двух частей: слушать событие на элементе, который переживает обновление, и проверять только свой сценарий. Для списка товаров таким элементом обычно становится постоянный контейнер #products.
Вызов $(selector).html(string) полностью заменяет содержимое каждого найденного элемента. Старые дочерние узлы исчезают из контейнера. Вместе с ними jQuery удаляет связанные данные и обработчики событий. Новая кнопка может иметь тот же класс и тот же текст, но это уже другой объект DOM.
Прямую привязку легко написать и трудно заметить в коде, который сначала рисует страницу, а потом начинает обновлять её частями.
\nvar $products = $('#products');\nfunction buy(event) { event.preventDefault(); window.console.log('Товар добавлен'); }\n$products.find('.js-buy').on('click', buy);\n$products.html(renderProducts(products));\nПосле .html() новая кнопка не имеет обработчика. Повторный вызов $products.find('.js-buy').on('click', buy) после каждого рендера маскирует проблему, но создаёт другой риск. Если функция вызывается дважды для той же кнопки, один клик вызывает buy дважды. Если обновление произошло между привязками, часть кнопок останется без события.
При делегировании обработчик привязывается не к кнопке, а к предку, который не заменяется. Браузер поднимает событие от кнопки к контейнеру. jQuery проверяет селектор делегирования и вызывает функцию, если путь события содержит подходящий элемент.
\nВажна граница. document технически переживёт почти любой рендер, но это слишком широкая зона: обработчик начнёт наблюдать весь документ. Лучше выбрать ближайший стабильный контейнер виджета. В примере это #products. Если сам контейнер тоже заменяется, делегирование надо поднять на его стабильного родителя или повторить привязку после замены контейнера.
var $products = $('#products');\nfunction buy(event) { event.preventDefault(); var $button = $(this); if ($button.prop('disabled')) return; $button.prop('disabled', true); window.console.log('Товар добавлен:', $button.data('productId')); }\n$products.on('click.catalog', '.js-buy', buy);\n$products.html(renderProducts(products));\nПространство имён .catalog не нужно для самого делегирования, но оно задаёт владельца события. Если виджет надо уничтожить, можно снять только свои обработчики: $products.off('.catalog'). Вызов .off('click') шире и может отключить код другого модуля, который слушает тот же контейнер.
Проверка .prop('disabled') в примере относится к отдельному контракту кнопки. Она не заменяет серверную защиту от повторной операции. Демонстрация только показывает, где можно временно блокировать элемент после клика и как не запускать локальную ветку повторно.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новая кнопка не реагирует | Прямой обработчик остался на удалённом узле | Привязать тестовый счётчик до и после .html() | Перенести обработчик на стабильный контейнер и использовать делегирование |
| Один клик вызывает два запроса | Инициализация добавила обработчик повторно | Вызвать mount дважды и сделать один клик | Убрать повторную прямую привязку; при необходимости снять свой namespace перед новой привязкой |
| После обновления ничего не меняется | Сам контейнер тоже заменён или селектор указывает не на него | Проверить document.contains($root[0]) и путь DOM | Поднять границу на живого родителя или повторить mount в точке замены контейнера |
| Отключился соседний сценарий | Использован общий .off('click') | Посмотреть namespace обработчиков в коде виджета | Назначить уникальный namespace и снимать только его |
| Событие не доходит до контейнера | Событие не всплывает или не подходит для делегирования | Проверить тип события и документацию jQuery | Использовать прямую привязку на устойчивом узле либо другой API |
Учебный пример ниже не измеряет работу конкретного production-каталога. Он проверяет только свойство делегирования: после нескольких замен HTML один клик вызывает один обработчик. В консоли страницы или в отдельной тестовой HTML-странице можно создать контейнер, выполнить инициализацию, заменить карточку и снова нажать кнопку.
\nvar calls = 0; var $products = $('<div id=\"products\"><button class=\"js-buy\">Купить</button></div>'); function mountCatalog($root) { $root.off('.catalogTest'); $root.on('click.catalogTest', '.js-buy', function () { calls += 1; }); } $('body').append($products); mountCatalog($products); $products.html('<button class=\"js-buy\">Купить</button>'); mountCatalog($products); $products.html('<button class=\"js-buy\">Купить</button>'); mountCatalog($products); $products.find('.js-buy').trigger('click'); window.console.assert(calls === 1, 'Ожидался один вызов, получено: ' + calls); $products.remove();\nВ этом тесте повторный mountCatalog безопасен, потому что сначала снимается namespace теста. Если проект гарантирует один mount на время жизни страницы, снятие можно не делать, но это условие должно быть видимым и проверяемым. Нельзя считать зелёным результатом только наличие кнопки на экране: нужно проверить число вызовов обработчика и состояние запроса.
.html().document, если живого контейнера достаточно..on().Делегирование не спасает, если заменяется выбранный корень. В таком случае нужно найти его живого родителя или вызвать mount после создания нового корня. Автоматически поднимать обработчик на document не стоит: область действия станет слишком широкой, а диагностика — сложнее.
Не каждое событие подходит для делегирования. Механизм зависит от всплытия события и поведения конкретной версии jQuery. Если событие не поднимается до корня, прямой обработчик на устойчивом узле может быть правильнее. Проверяйте это по документации и минимальным примером, а не переносите шаблон на все события.
\nСтрока для .html() должна быть доверенной или безопасно обработанной. Сама замена DOM и делегирование не решают XSS. Не вставляйте в HTML непроверенные значения из URL, cookie или формы. Также не следует выдавать учебный счётчик за результат production-нагрузки: он проверяет только порядок DOM-операций.
Решение готово, если после трёх последовательных mount и двух замен содержимого контейнера одна новая кнопка вызывает ровно один обработчик. Соседний обработчик не отключается. При удалении виджета его namespace снимается, а запрос, который уже ушёл на сервер, не считается отменённым. Эти условия можно выполнить в браузере, зафиксировать в тесте и повторить после следующего изменения рендера.
\n