8 lines
17 KiB
JSON
8 lines
17 KiB
JSON
{
|
||
"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 = $('<div id=\"order-panel\">' +\n '<button class=\"js-order-submit\" type=\"button\">Оформить</button>' +\n '<button class=\"js-help\" type=\"button\">Помощь</button>' +\n '</div>');\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>"
|
||
}
|