Files

8 lines
17 KiB
JSON
Raw Permalink 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": 348,
"slug": "editorial-2018-05-practice-legacy-jquery",
"title": "Legacy jQuery: как повторно инициализировать виджет без двойной отправки",
"excerpt": "Повторный mount jQuery-виджета накапливает обработчики, если код только вызывает .on(). Разбираем namespace, делегирование, границу корня и проверку: три инициализации должны дать один вызов.",
"contentHtml": "<p>Кнопка заказа выглядит исправной, но один физический клик отправляет два запроса. После следующей Ajax-замены разметки их становится три. Пользователь видит один интерфейс, а браузер запускает несколько обработчиков. Цена ошибки — дублированная операция, лишний запрос и состояние, которое серверу приходится разруливать отдельно.</p>\n<p>В legacy-интерфейсе причина обычно появляется в момент повторного mount: страница создаёт форму, затем обновляет её через Ajax и снова вызывает ту же функцию. Если функция каждый раз только добавляет обработчик через <code>.on()</code>, старые обработчики остаются. Нам нужен небольшой контракт: один стабильный корень, своё пространство имён и проверка «несколько mount — один клик».</p>\n<h2>Сначала фиксируем воспроизведение</h2>\n<p>Перед исправлением отделите событие браузера от сетевого запроса. Сначала считайте вызовы обработчика, а не ответы API. Так можно доказать, что ошибка возникла в DOM-коде, и не смешать её с повтором на сервере.</p>\n<pre><code>function mountOrderForm() {\n $('.js-order-submit').on('click', function (event) {\n event.preventDefault();\n sendOrder();\n });\n}\n\nmountOrderForm();\nmountOrderForm(); // один клик вызовет sendOrder дважды</code></pre>\n<p>В этом примере два вызова выбирают одну и ту же кнопку и добавляют два обработчика. jQuery не считает второй вызов дубликатом только потому, что селектор и тело функции совпадают. При клике оба обработчика выполняют свой код.</p>\n<figure><img src=\"/assets/editorial/2018/jquery-reinit-namespaces.svg\" alt=\"Три шага повторного mount jQuery-виджета: снять свой namespace на корне и назначить один делегированный обработчик\" /><figcaption>Mount сначала снимает события своего пространства имён, затем назначает один делегированный обработчик на устойчивый контейнер.</figcaption></figure>\n<h2>Что именно хранит jQuery</h2>\n<p><code>.on()</code> прикрепляет обработчик к выбранным элементам. Если передать второй аргумент-селектор, обработчик становится делегированным: jQuery слушает событие на выбранном элементе и проверяет подходящих потомков при всплытии. Без селектора обработчик назначается напрямую на текущий набор узлов.</p>\n<p>Пространство имён — суффикс после точки, например <code>click.orderForm</code>. Оно не создаёт отдельное дерево событий и не делает имена иерархическими. Это фильтр для последующего <code>.off()</code>: <code>off('.orderForm')</code> на конкретном корне снимает обработчики этого namespace, но не безымянный <code>click</code> соседнего модуля. Поэтому имя должно быть уникальным для владельца виджета.</p>\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>mount повторно вызвал <code>.on()</code></td><td>Поставить счётчик и вызвать mount дважды</td><td>Перед <code>.on()</code> выполнить <code>off</code> своего namespace</td></tr><tr><td>После Ajax новая кнопка молчит</td><td>Обработчик был на старом DOM-узле</td><td>Сравнить узел до и после замены HTML</td><td>Слушать постоянный контейнер через селектор потомка</td></tr><tr><td>Пропал чужой клик</td><td>Очистка выполнена по общему типу события</td><td>Найти вызовы <code>off('click')</code></td><td>Использовать конкретный namespace и корень виджета</td></tr><tr><td>Двойной запрос остался</td><td>Есть второй корень или сервер повторяет операцию</td><td>Разделить лог событий и сетевых запросов</td><td>Оставить одну точку владения и защитить серверную операцию</td></tr></tbody></table></div>\n<h2>Идемпотентный mount</h2>\n<p>Пусть <code>#order-panel</code> — единственный корень виджета, а кнопка отправки имеет класс <code>.js-order-submit</code>. Все события этого модуля получают namespace <code>.orderForm</code>. Тогда повторный mount можно сделать безопасным: сначала очистить только своё пространство имён на одном корне, затем назначить один делегированный обработчик.</p>\n<pre><code>(function ($) {\n var namespace = '.orderForm';\n\n function sendOrder($button) {\n // Учебный эффект. Реальный Ajax здесь не выполняется.\n window.orderCalls = (window.orderCalls || 0) + 1;\n $button.addClass('is-pending');\n }\n\n function mountOrderForm(root) {\n var $root = $(root);\n if ($root.length !== 1) {\n throw new Error('Нужен ровно один контейнер заказа');\n }\n $root.off(namespace);\n $root.on('click' + namespace, '.js-order-submit', function (event) {\n event.preventDefault();\n sendOrder($(this));\n });\n }\n\n window.mountOrderForm = mountOrderForm;\n}(jQuery));\n\nmountOrderForm('#order-panel');</code></pre>\n<p>Проверка количества корней должна происходить до изменения событий. Затем <code>off(namespace)</code> действует только на выбранный контейнер, а <code>on('click' + namespace, selector, handler)</code> оставляет один делегированный обработчик. Порядок важен: если вызвать <code>off</code> после <code>on</code>, только что созданный обработчик тоже исчезнет. Если вызвать <code>off('click')</code>, можно снять чужие обработчики.</p>\n<h2>Граница между внутренним HTML и корнем</h2>\n<p>Прямой обработчик хранится на конкретной кнопке. Когда код заменяет содержимое контейнера через <code>.html()</code>, старая кнопка удаляется вместе с данными и обработчиками. Новая кнопка имеет тот же класс, но это уже другой DOM-узел.</p>\n<p>При делегировании обработчик хранится на контейнере, который остаётся в DOM. Событие от новой кнопки всплывает до контейнера, а jQuery проверяет селектор <code>.js-order-submit</code>. Поэтому после замены внутреннего HTML mount не нужен. Если же заменяется сам <code>#order-panel</code>, исчезает и обработчик на корне. После вставки нового корня mount нужно вызвать снова.</p>\n<pre><code>var $panel = $('#order-panel');\n\n$panel.on('click.orderForm', '.js-order-submit', handleOrder);\n$panel.html(renderNewButtons());\n\n// Кнопка внутри прежнего #order-panel работает.\n// Если renderNewButtons() заменил сам #order-panel,\n// обработчик нужно назначить на новый корень.</code></pre>\n<p>Выбирайте ближайший стабильный контейнер, а не <code>document</code> по умолчанию. Большое глобальное дерево усложняет поиск владельца события и заставляет jQuery проверять больше делегированных селекторов на каждом клике.</p>\n<h2>Проверка: три mount, две кнопки и соседний модуль</h2>\n<p>Тест должен проверять не только положительный путь. Он должен показать, что обработчик не накапливается, переживает замену дочерней кнопки и не снимает событие другого namespace.</p>\n<pre><code>var calls = 0;\nvar helpCalls = 0;\nvar $panel = $('&lt;div id=\"order-panel\">' +\n '&lt;button class=\"js-order-submit\" type=\"button\">Оформить&lt;/button&gt;' +\n '&lt;button class=\"js-help\" type=\"button\">Помощь&lt;/button&gt;' +\n '&lt;/div&gt;');\n\nfunction mountDemo(root) {\n var $root = $(root);\n $root.off('.demoOrder');\n $root.on('click.demoOrder', '.js-order-submit', function (event) {\n event.preventDefault();\n calls += 1;\n });\n}\n\n$panel.on('click.otherModule', '.js-help', function () {\n helpCalls += 1;\n});\n$('body').append($panel);\nmountDemo('#order-panel');\nmountDemo('#order-panel');\nmountDemo('#order-panel');\n$panel.find('.js-order-submit').trigger('click');\n$panel.find('.js-help').trigger('click');\nconsole.assert(calls === 1, 'Ожидался один вызов, получено: ' + calls);\nconsole.assert(helpCalls === 1, 'Соседний обработчик должен сохраниться');\n\n$panel.html('<button class=\"js-order-submit\" type=\"button\">Повторить</button>');\n$panel.find('.js-order-submit').trigger('click');\nconsole.assert(calls === 2, 'Новая кнопка должна вызвать обработчик один раз');\n$panel.remove();</code></pre>\n<p>Ожидаемый результат — <code>calls === 2</code> после двух разных кнопок и <code>helpCalls === 1</code>. Если убрать <code>$root.off('.demoOrder')</code>, первый счётчик станет равен трём. Если заменить делегирование на прямую привязку к кнопке, клик после <code>.html()</code> не будет обработан. Если заменить namespace на общий <code>click</code>, очистка может затронуть кнопку помощи.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Найдите все места, где функция вызывается после загрузки и после Ajax-обновления. Зафиксируйте, какой узел переживает замену.</li><li>Выберите один корень и проверьте, что селектор находит ровно один элемент в момент mount.</li><li>Назначьте уникальный namespace, например <code>.orderForm</code>, а не общий <code>.form</code>.</li><li>Перед назначением удаляйте только этот namespace на выбранном корне.</li><li>Если дочерние кнопки создаются заново, используйте делегирование через селектор потомка.</li><li>Вызовите mount три раза, выполните клик, замените внутренний HTML и повторите клик.</li><li>Отдельно проверьте сетевой сценарий и серверную защиту от повторной операции.</li></ol>\n<h2>Границы и отрицательный путь</h2>\n<p>Схема требует jQuery 1.7 или новее, потому что <code>.on()</code> и <code>.off()</code> появились в этой версии. Старые методы <code>.bind()</code>, <code>.live()</code> и <code>.delegate()</code> имеют другой путь миграции. Нельзя механически заменить вызовы, не проверив жизненный цикл разметки.</p>\n<p>Namespace действует только там, где вызван <code>.off()</code>. Он не удаляет обработчики с другого корня. Если виджет монтируется одновременно на внешний контейнер и на дочерний блок, один клик может пройти через два обработчика. Оставьте одну точку владения и проверьте её количеством найденных корней.</p>\n<p>Делегирование подходит для событий, которые доходят до корня. Оно не исправляет неправильный селектор, остановку всплытия через <code>stopPropagation()</code> или обработчик, который сам вызывает действие дважды. Оно также не делает повторный HTTP-запрос безопасным. Для заказа нужна серверная проверка и, при необходимости, идемпотентный ключ.</p>\n<p>Не вставляйте непроверенную строку из формы, URL или cookie через <code>.html()</code>. Это отдельный риск выполнения HTML и скриптов; исправление событийного namespace его не устраняет.</p>\n<h2>Критерий готовности</h2>\n<p>Решение готово, когда один корень проходит тест с тремя вызовами mount, один клик вызывает обработчик ровно один раз, новая кнопка после замены внутреннего HTML тоже вызывает его один раз, а соседний обработчик с другим namespace продолжает работать. Затем отдельно проверяется повторный сетевой запрос и реакция сервера. Учебный счётчик подтверждает только браузерный контракт и не доказывает, что операция на сервере выполнилась один раз.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://api.jquery.com/on/\" target=\"_blank\" rel=\"noopener\">jQuery API: .on()</a> — официальный синтаксис прямой и делегированной привязки, namespaces, всплытия и версия добавления 1.7.</li><li><a href=\"https://api.jquery.com/off/\" target=\"_blank\" rel=\"noopener\">jQuery API: .off()</a> — официальный способ снимать обработчики по типу события, namespace, селектору и функции.</li><li><a href=\"https://api.jquery.com/html/\" target=\"_blank\" rel=\"noopener\">jQuery API: .html()</a> — официальное описание полной замены содержимого, очистки данных и обработчиков дочерних узлов, а также риска непроверенного HTML.</li></ul>"
}