Files
progcode/editorial/agent-rewrites/347.json
T

8 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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> к текущей кнопке, а <code>.html()</code> заменяет содержимое контейнера и создаёт новый DOM-узел. Решение состоит из двух частей: слушать событие на элементе, который переживает замену, и сделать повторную инициализацию безопасной. Для списка товаров таким элементом обычно становится постоянный контейнер <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> устанавливает HTML-содержимое каждого найденного элемента. Старые дочерние узлы полностью заменяются новыми. В документации jQuery отдельно указано: перед этой заменой библиотека удаляет с дочерних узлов связанные данные и обработчики событий. Новая кнопка может иметь тот же класс и текст, но это уже другой объект DOM.</p>\n<p>Прямую привязку легко написать в коде, который сначала рисует страницу, а потом начинает обновлять её частями:</p>\n<pre><code>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));</code></pre>\n<p>После <code>.html()</code> новая кнопка не имеет этого обработчика. Повторный вызов <code>$products.find('.js-buy').on('click', buy)</code> после каждого рендера маскирует проблему, но создаёт другой риск: если функция запускается дважды для той же кнопки, jQuery добавляет обработчик дважды. Поэтому сначала надо установить, какой узел заменяется и кто владеет инициализацией.</p>\n<h2>Механизм делегирования</h2>\n<p>При делегировании обработчик привязывается не к кнопке, а к предку, который не заменяется. Событие <code>click</code> возникает на цели и обычно всплывает к родителям. jQuery проверяет переданный селектор на элементах этого пути и вызывает обработчик для совпавшего потомка.</p>\n<p>У этого решения есть чёткая граница. <code>document</code> переживёт почти любой рендер, но станет слишком широкой зоной наблюдения: туда попадут несвязанные кнопки, а путь диагностики усложнится. Лучше выбрать ближайший стабильный контейнер виджета. Если заменяется и сам контейнер, делегирование надо поднять на его живого родителя или повторить mount в точке, где создан новый контейнер.</p>\n<pre><code>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));</code></pre>\n<p>Второй аргумент <code>'.js-buy'</code> делает обработчик делегированным. Он будет работать и для кнопок, появившихся после привязки, пока <code>#products</code> остаётся в документе. В делегированном обработчике <code>this</code> — найденная кнопка, а <code>event.target</code> может быть вложенной иконкой, от которой началось событие.</p>\n<p>Пространство имён <code>.catalog</code> не требуется для делегирования, но задаёт владельца события. При уничтожении виджета можно снять только свои обработчики: <code>$products.off('.catalog')</code>. Вызов <code>$products.off('click')</code> шире и может отключить другие сценарии, слушающие тот же контейнер. Namespace не исправляет повторную привязку сам по себе: перед повторным mount нужен явный <code>off</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 перед новой привязкой или гарантировать один mount</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>Найти всех владельцев обработчиков на контейнере</td><td>Назначить уникальный namespace и снимать только его</td></tr><tr><td>Событие не доходит до контейнера</td><td>Событие не всплывает или элемент находится в SVG</td><td>Проверить тип события и документацию jQuery</td><td>Использовать прямую привязку на устойчивом HTML-узле или другой API</td></tr></tbody></table></div>\n<h2>Минимальная проверка</h2>\n<p>Учебный fixture ниже не измеряет работу конкретного каталога. Он отдельно показывает две ошибки: прямая инициализация на одном узле даёт два вызова, а делегированная инициализация с повторным mount и двумя заменами HTML оставляет один вызов. Такой тест проверяет поведение обработчиков, но не подтверждает серверную защиту от повторной покупки.</p>\n<pre><code>var directCalls = 0;\nvar delegatedCalls = 0;\nvar $products = $('&lt;div id=\"products\"&gt;&lt;button class=\"js-buy\"&gt;Купить&lt;/button&gt;&lt;/div&gt;');\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('&lt;button class=\"js-buy\"&gt;Купить&lt;/button&gt;');\ndelegatedMount($products);\n$products.html('&lt;button class=\"js-buy\"&gt;Купить&lt;/button&gt;');\n$products.find('.js-buy').trigger('click');\nwindow.console.assert(delegatedCalls === 1, 'Делегирование вызвалось: ' + delegatedCalls);\n\n$products.off('.delegatedTest').remove();</code></pre>\n<p>Вызов <code>delegatedMount</code> перед новой привязкой снимает только тестовый namespace. Поэтому повторный mount не накапливает обработчики. Отдельная проверка <code>directCalls === 2</code> нужна, чтобы не спутать «кнопка видна» с корректным числом вызовов. В рабочем коде после этого следует проверить сетевой запрос и серверный ответ.</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> не стоит: область действия станет слишком широкой, а проверка селекторов — дороже. Документация jQuery также отдельно ограничивает делегированные обработчики для SVG; приведённый пример относится к HTML-кнопкам.</p>\n<p>Не каждое событие подходит для делегирования. В jQuery <code>load</code>, <code>scroll</code> и <code>error</code> не всплывают, поэтому для них нужен прямой обработчик на узле, который действительно живёт достаточно долго. Для <code>focus</code> и <code>blur</code> у делегирования есть отдельное поведение jQuery через <code>focusin</code> и <code>focusout</code>. Переносить шаблон <code>click</code> на любой тип события без проверки нельзя.</p>\n<p>Строка для <code>.html()</code> должна быть доверенной или безопасно обработанной. Официальная документация предупреждает, что HTML-строки из URL, cookie или формы могут привести к XSS. Сама замена DOM и делегирование не решают эту проблему: непроверенные значения надо экранировать или вставлять способом, который не интерпретирует их как HTML.</p>\n<p>Учебный счётчик не является замером 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> — описывает замену содержимого, удаление данных и обработчиков дочерних узлов, а также предупреждает о HTML-строках из непроверенных источников.</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><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Event/bubbles\" target=\"_blank\" rel=\"noopener\">MDN: Event.bubbles</a> — фиксирует свойство, по которому можно проверить, всплывает ли конкретное событие.</li></ul>"
}