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