{ "index": 347, "slug": "editorial-2018-05-mechanism-legacy-jquery", "title": "jQuery. Почему кнопка перестаёт работать после .html()", "excerpt": "После обновления каталога через Ajax карточки видны, но кнопка больше не реагирует. Разбираем, почему .html() удаляет обработчики дочерних узлов, как выбрать живой контейнер для делегирования и как проверить решение без дублирования кликов.", "contentHtml": "

Каталог обновляет список товаров через Ajax. На экране появляются новые карточки, но кнопка «В корзину» не реагирует. Если повторно вызвать инициализацию, новые кнопки начинают работать, а при повторном запуске на прежних узлах один клик может вызвать два обработчика. Цена ошибки — лишний запрос к корзине или повторная операция, а поиск причины превращается в проверку случайного порядка Ajax, рендера и привязки событий.

\n

Проблема возникает не потому, что jQuery «забывает» обработчик. Код привязывает click к текущей кнопке, а .html() заменяет содержимое контейнера и создаёт новый DOM-узел. Решение состоит из двух частей: слушать событие на элементе, который переживает замену, и сделать повторную инициализацию безопасной. Для списка товаров таким элементом обычно становится постоянный контейнер #products.

\n
\"Прямой
Прямой обработчик живёт на заменённой кнопке. Делегированный обработчик остаётся на контейнере и видит новую кнопку.
\n

Что именно меняет .html()

\n

Вызов $(selector).html(string) устанавливает HTML-содержимое каждого найденного элемента. Старые дочерние узлы полностью заменяются новыми. В документации jQuery отдельно указано: перед этой заменой библиотека удаляет с дочерних узлов связанные данные и обработчики событий. Новая кнопка может иметь тот же класс и текст, но это уже другой объект DOM.

\n

Прямую привязку легко написать в коде, который сначала рисует страницу, а потом начинает обновлять её частями:

\n
var $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 добавляет обработчик дважды. Поэтому сначала надо установить, какой узел заменяется и кто владеет инициализацией.

\n

Механизм делегирования

\n

При делегировании обработчик привязывается не к кнопке, а к предку, который не заменяется. Событие click возникает на цели и обычно всплывает к родителям. jQuery проверяет переданный селектор на элементах этого пути и вызывает обработчик для совпавшего потомка.

\n

У этого решения есть чёткая граница. document переживёт почти любой рендер, но станет слишком широкой зоной наблюдения: туда попадут несвязанные кнопки, а путь диагностики усложнится. Лучше выбрать ближайший стабильный контейнер виджета. Если заменяется и сам контейнер, делегирование надо поднять на его живого родителя или повторить mount в точке, где создан новый контейнер.

\n
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 может быть вложенной иконкой, от которой началось событие.

\n

Пространство имён .catalog не требуется для делегирования, но задаёт владельца события. При уничтожении виджета можно снять только свои обработчики: $products.off('.catalog'). Вызов $products.off('click') шире и может отключить другие сценарии, слушающие тот же контейнер. Namespace не исправляет повторную привязку сам по себе: перед повторным mount нужен явный off только для своего имени.

\n

Проверка .prop('disabled') в примере относится к отдельному контракту кнопки. Она не заменяет серверную защиту от повторной операции и не отменяет запрос, который уже ушёл. Её задача в этом фрагменте — показать локальную границу обработчика, а не объявить операцию идемпотентной.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Новая кнопка не реагируетПрямой обработчик остался на удалённом узлеПроверить обработчик до и после .html()Перенести его на стабильный контейнер и использовать делегирование
Один клик вызывает два обработчикаИнициализация добавила привязку повторноВызвать mount дважды и сделать один кликСнять свой namespace перед новой привязкой или гарантировать один mount
После обновления ничего не меняетсяСам контейнер тоже заменён или селектор указывает не на негоПроверить document.contains($root[0]) и путь DOMПоднять границу на живого родителя или повторить mount после создания корня
Отключился соседний сценарийИспользован общий .off('click')Найти всех владельцев обработчиков на контейнереНазначить уникальный namespace и снимать только его
Событие не доходит до контейнераСобытие не всплывает или элемент находится в SVGПроверить тип события и документацию jQueryИспользовать прямую привязку на устойчивом HTML-узле или другой API
\n

Минимальная проверка

\n

Учебный fixture ниже не измеряет работу конкретного каталога. Он отдельно показывает две ошибки: прямая инициализация на одном узле даёт два вызова, а делегированная инициализация с повторным mount и двумя заменами HTML оставляет один вызов. Такой тест проверяет поведение обработчиков, но не подтверждает серверную защиту от повторной покупки.

\n
var 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 нужна, чтобы не спутать «кнопка видна» с корректным числом вызовов. В рабочем коде после этого следует проверить сетевой запрос и серверный ответ.

\n

Порядок действий

\n
  1. Зафиксировать симптом: какая разметка заменяется, какая кнопка перестаёт работать и сколько обработчиков или запросов приходится на один клик.
  2. Проверить, заменяется ли сама кнопка или весь контейнер. Посмотреть, какой DOM-узел остаётся в документе после .html().
  3. Выбрать ближайший стабильный корень виджета. Не начинать с document, если живого контейнера достаточно.
  4. Перенести обработчик на корень и передать селектор кнопки вторым аргументом .on().
  5. Назначить уникальное пространство имён. Если mount может повториться, перед новой привязкой снять только этот namespace.
  6. Повторить сценарий после двух замен HTML и сделать один программный или ручной клик. Сравнить число вызовов до и после.
  7. Отдельно проверить серверный контракт: клиентское делегирование не отменяет уже отправленный запрос и не делает повторную операцию идемпотентной.
\n

Ограничения и отрицательный путь

\n

Делегирование не спасает, если заменяется выбранный корень. В таком случае нужно найти его живого родителя или вызвать mount после создания нового корня. Автоматически поднимать обработчик на document не стоит: область действия станет слишком широкой, а проверка селекторов — дороже. Документация jQuery также отдельно ограничивает делегированные обработчики для SVG; приведённый пример относится к HTML-кнопкам.

\n

Не каждое событие подходит для делегирования. В jQuery load, scroll и error не всплывают, поэтому для них нужен прямой обработчик на узле, который действительно живёт достаточно долго. Для focus и blur у делегирования есть отдельное поведение jQuery через focusin и focusout. Переносить шаблон click на любой тип события без проверки нельзя.

\n

Строка для .html() должна быть доверенной или безопасно обработанной. Официальная документация предупреждает, что HTML-строки из URL, cookie или формы могут привести к XSS. Сама замена DOM и делегирование не решают эту проблему: непроверенные значения надо экранировать или вставлять способом, который не интерпретирует их как HTML.

\n

Учебный счётчик не является замером production-нагрузки. Он проверяет порядок DOM-операций и количество локальных вызовов. Реальный каталог дополнительно проверяет блокировку кнопки, повторную отправку, ответ сервера и поведение после ошибки сети.

\n

Критерий готовности

\n

Решение готово, если после повторного mount и двух последовательных замен содержимого контейнера одна новая кнопка вызывает ровно один обработчик. Соседний сценарий не отключается. При удалении виджета его namespace снимается, а уже отправленный запрос не считается отменённым. Эти условия можно выполнить в браузере, зафиксировать в тесте и повторить после следующего изменения рендера.

\n

Проверяемые источники

\n" }