8 lines
18 KiB
JSON
8 lines
18 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> к текущей кнопке, а <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 = $('<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();</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>"
|
||
}
|