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.

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

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

\n

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

\n

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

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

\n

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

\n

При делегировании обработчик привязывается не к кнопке, а к предку, который не заменяется. Браузер поднимает событие от кнопки к контейнеру. jQuery проверяет селектор делегирования и вызывает функцию, если путь события содержит подходящий элемент.

\n

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

\n
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') шире и может отключить код другого модуля, который слушает тот же контейнер.

\n

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

\n

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

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

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

\n

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

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

\n

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

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

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n"} +{ + "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" +}