diff --git a/editorial/agent-rewrites/347.json b/editorial/agent-rewrites/347.json index a868f99..686b9c0 100644 --- a/editorial/agent-rewrites/347.json +++ b/editorial/agent-rewrites/347.json @@ -1 +1,7 @@ -{"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Каталог обновляет список товаров через 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