2 lines
16 KiB
JSON
2 lines
16 KiB
JSON
{"index":347,"slug":"editorial-2018-05-mechanism-legacy-jquery","title":"jQuery. Почему кнопка перестаёт работать после .html()","excerpt":"После обновления каталога через Ajax карточки видны, но кнопка больше не реагирует. Разбираем, почему .html() удаляет обработчики дочерних узлов, как выбрать живой контейнер для делегирования и как проверить решение без дублирования кликов.","contentHtml":"<p>Каталог обновляет список товаров через Ajax. На экране появляются новые карточки, но кнопка «В корзину» не реагирует. Если повторно вызвать функцию инициализации, новые кнопки начинают работать. Затем старые и новые кнопки иногда отправляют по два запроса. Цена ошибки — один клик меняет состояние заказа несколько раз, а поиск причины превращается в проверку случайного порядка Ajax, рендера и привязки событий.</p>\n<p>Проблема возникает не потому, что jQuery «забывает» обработчик. Код привязывает <code>click</code> к старому DOM-узлу, а <code>.html()</code> удаляет этот узел и создаёт новый. Решение состоит из двух частей: слушать событие на элементе, который переживает обновление, и проверять только свой сценарий. Для списка товаров таким элементом обычно становится постоянный контейнер <code>#products</code>.</p>\n<figure><img src=\"/assets/editorial/2018/jquery-delegation-after-html.svg\" alt=\"Прямой и делегированный обработчики jQuery после замены содержимого контейнера\" /><figcaption>Прямой обработчик живёт на заменённой кнопке. Делегированный обработчик остаётся на контейнере и видит новую кнопку.</figcaption></figure>\n<h2>Что именно меняет .html()</h2>\n<p>Вызов <code>$(selector).html(string)</code> полностью заменяет содержимое каждого найденного элемента. Старые дочерние узлы исчезают из контейнера. Вместе с ними jQuery удаляет связанные данные и обработчики событий. Новая кнопка может иметь тот же класс и тот же текст, но это уже другой объект DOM.</p>\n<p>Прямую привязку легко написать и трудно заметить в коде, который сначала рисует страницу, а потом начинает обновлять её частями.</p>\n<pre><code>var $products = $('#products');\nfunction buy(event) { event.preventDefault(); window.console.log('Товар добавлен'); }\n$products.find('.js-buy').on('click', buy);\n$products.html(renderProducts(products));</code></pre>\n<p>После <code>.html()</code> новая кнопка не имеет обработчика. Повторный вызов <code>$products.find('.js-buy').on('click', buy)</code> после каждого рендера маскирует проблему, но создаёт другой риск. Если функция вызывается дважды для той же кнопки, один клик вызывает <code>buy</code> дважды. Если обновление произошло между привязками, часть кнопок останется без события.</p>\n<h2>Механизм делегирования</h2>\n<p>При делегировании обработчик привязывается не к кнопке, а к предку, который не заменяется. Браузер поднимает событие от кнопки к контейнеру. jQuery проверяет селектор делегирования и вызывает функцию, если путь события содержит подходящий элемент.</p>\n<p>Важна граница. <code>document</code> технически переживёт почти любой рендер, но это слишком широкая зона: обработчик начнёт наблюдать весь документ. Лучше выбрать ближайший стабильный контейнер виджета. В примере это <code>#products</code>. Если сам контейнер тоже заменяется, делегирование надо поднять на его стабильного родителя или повторить привязку после замены контейнера.</p>\n<pre><code>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));</code></pre>\n<p>Пространство имён <code>.catalog</code> не нужно для самого делегирования, но оно задаёт владельца события. Если виджет надо уничтожить, можно снять только свои обработчики: <code>$products.off('.catalog')</code>. Вызов <code>.off('click')</code> шире и может отключить код другого модуля, который слушает тот же контейнер.</p>\n<p>Проверка <code>.prop('disabled')</code> в примере относится к отдельному контракту кнопки. Она не заменяет серверную защиту от повторной операции. Демонстрация только показывает, где можно временно блокировать элемент после клика и как не запускать локальную ветку повторно.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Новая кнопка не реагирует</td><td>Прямой обработчик остался на удалённом узле</td><td>Привязать тестовый счётчик до и после <code>.html()</code></td><td>Перенести обработчик на стабильный контейнер и использовать делегирование</td></tr><tr><td>Один клик вызывает два запроса</td><td>Инициализация добавила обработчик повторно</td><td>Вызвать mount дважды и сделать один клик</td><td>Убрать повторную прямую привязку; при необходимости снять свой namespace перед новой привязкой</td></tr><tr><td>После обновления ничего не меняется</td><td>Сам контейнер тоже заменён или селектор указывает не на него</td><td>Проверить <code>document.contains($root[0])</code> и путь DOM</td><td>Поднять границу на живого родителя или повторить mount в точке замены контейнера</td></tr><tr><td>Отключился соседний сценарий</td><td>Использован общий <code>.off('click')</code></td><td>Посмотреть namespace обработчиков в коде виджета</td><td>Назначить уникальный namespace и снимать только его</td></tr><tr><td>Событие не доходит до контейнера</td><td>Событие не всплывает или не подходит для делегирования</td><td>Проверить тип события и документацию jQuery</td><td>Использовать прямую привязку на устойчивом узле либо другой API</td></tr></tbody></table></div>\n<h2>Минимальная проверка</h2>\n<p>Учебный пример ниже не измеряет работу конкретного production-каталога. Он проверяет только свойство делегирования: после нескольких замен HTML один клик вызывает один обработчик. В консоли страницы или в отдельной тестовой HTML-странице можно создать контейнер, выполнить инициализацию, заменить карточку и снова нажать кнопку.</p>\n<pre><code>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();</code></pre>\n<p>В этом тесте повторный <code>mountCatalog</code> безопасен, потому что сначала снимается namespace теста. Если проект гарантирует один mount на время жизни страницы, снятие можно не делать, но это условие должно быть видимым и проверяемым. Нельзя считать зелёным результатом только наличие кнопки на экране: нужно проверить число вызовов обработчика и состояние запроса.</p>\n<h2>Порядок действий</h2>\n<ol><li>Зафиксировать симптом: какая разметка заменяется, какая кнопка перестаёт работать и сколько запросов уходит на один клик.</li><li>Проверить, заменяется ли сама кнопка или весь контейнер. Посмотреть, какой DOM-узел остаётся в документе после <code>.html()</code>.</li><li>Выбрать ближайший стабильный корень виджета. Не начинать с <code>document</code>, если живого контейнера достаточно.</li><li>Перенести обработчик на корень и передать селектор кнопки вторым аргументом <code>.on()</code>.</li><li>Назначить уникальное пространство имён. Если mount может повториться, перед новой привязкой снять только этот namespace.</li><li>Повторить сценарий после двух замен HTML и сделать один программный или ручной клик.</li><li>Отдельно проверить серверный контракт: клиентское делегирование не отменяет уже отправленный запрос и не делает повторную операцию идемпотентной.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Делегирование не спасает, если заменяется выбранный корень. В таком случае нужно найти его живого родителя или вызвать mount после создания нового корня. Автоматически поднимать обработчик на <code>document</code> не стоит: область действия станет слишком широкой, а диагностика — сложнее.</p>\n<p>Не каждое событие подходит для делегирования. Механизм зависит от всплытия события и поведения конкретной версии jQuery. Если событие не поднимается до корня, прямой обработчик на устойчивом узле может быть правильнее. Проверяйте это по документации и минимальным примером, а не переносите шаблон на все события.</p>\n<p>Строка для <code>.html()</code> должна быть доверенной или безопасно обработанной. Сама замена DOM и делегирование не решают XSS. Не вставляйте в HTML непроверенные значения из URL, cookie или формы. Также не следует выдавать учебный счётчик за результат production-нагрузки: он проверяет только порядок DOM-операций.</p>\n<h2>Критерий готовности</h2>\n<p>Решение готово, если после трёх последовательных mount и двух замен содержимого контейнера одна новая кнопка вызывает ровно один обработчик. Соседний обработчик не отключается. При удалении виджета его namespace снимается, а запрос, который уже ушёл на сервер, не считается отменённым. Эти условия можно выполнить в браузере, зафиксировать в тесте и повторить после следующего изменения рендера.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://api.jquery.com/html/\" target=\"_blank\" rel=\"noopener\">jQuery API: .html()</a> — описывает полную замену содержимого и удаление данных и обработчиков дочерних узлов.</li><li><a href=\"https://api.jquery.com/on/\" target=\"_blank\" rel=\"noopener\">jQuery API: .on()</a> — описывает прямую и делегированную привязку событий.</li><li><a href=\"https://api.jquery.com/off/\" target=\"_blank\" rel=\"noopener\">jQuery API: .off()</a> — описывает снятие обработчиков по типу события, селектору и пространству имён.</li></ul>"}
|