Files
progcode/editorial/agent-rewrites/348.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
15 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": 348,
"slug": "editorial-2018-05-practice-legacy-jquery",
"title": "Legacy jQuery: как повторно инициализировать виджет без двойного клика",
"excerpt": "Повторный mount jQuery-виджета накапливает обработчики, если код только вызывает .on(). Разбираем namespace, делегирование, отрицательный путь и проверку: три инициализации должны дать один вызов.",
"contentHtml": "<p>Кнопка заказа выглядит исправной, но один клик отправляет два запроса. После следующей Ajax-замены разметки — три. Пользователь видит один интерфейс, а браузер выполняет несколько обработчиков. Цена ошибки — дублированная операция, лишний запрос и состояние, которое серверу приходится разруливать отдельно.</p>\n<p>В legacy-интерфейсе это начинается с безобидного вызова <code>mountOrderForm()</code>. Страница загружает форму, затем код обновляет её через Ajax и вызывает тот же mount ещё раз. Если функция каждый раз только добавляет обработчик через <code>.on()</code>, старые обработчики не исчезают. Нужен контракт: постоянный корень виджета, уникальное пространство имён и проверка «несколько mount — один клик».</p>\n<h2>Симптом и механизм</h2>\n<p><code>.on()</code> прикрепляет функцию к выбранным DOM-узлам. Повторный вызов с тем же селектором не распознаёт прежнюю функцию как дубликат. Он добавляет ещё одну. При клике браузер запускает обе функции. Если каждая вызывает отправку формы или Ajax, действие повторяется.</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>Глобальный <code>$(document).off('click')</code> не является исправлением. Он может снять обработчики другого модуля. Граница очистки должна совпадать с границей владения. Если виджет владеет <code>#order-panel</code>, очищайте его события и только его namespace.</p>\n<figure><img src='/assets/editorial/2018/jquery-reinit-namespaces.svg' alt='Повторная инициализация jQuery-виджета через очистку namespace и делегированный обработчик' /><figcaption>Mount сначала снимает события своего пространства имён, затем назначает один делегированный обработчик на устойчивый контейнер.</figcaption></figure>\n<h2>Контракт повторной инициализации</h2>\n<p>В DOM существует один контейнер <code>#order-panel</code>. Кнопка отправки имеет класс <code>.js-order-submit</code>. Все события виджета получают namespace <code>.orderForm</code>. После каждого успешного вызова mount контейнер содержит один обработчик этого виджета. Это проверяется поведением: после трёх mount один программный клик увеличивает счётчик на единицу.</p>\n<p>Namespace — часть имени события, например <code>click.orderForm</code>. Он не создаёт отдельное дерево. Он даёт фильтр для <code>.off()</code>. Поэтому <code>off('.orderForm')</code> удаляет события виджета на выбранном объекте, но не снимает безымянный <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>Рабочая схема</h2>\n<p>Постоянный контейнер принимает событие от кнопки через делегирование. Селектор потомка передаётся вторым аргументом <code>.on()</code>. Поэтому обработчик не нужно назначать заново, когда кнопка появляется внутри контейнера.</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> создаёт один делегированный обработчик. Порядок важен. Если очищать namespace после <code>.on()</code>, новый обработчик тоже исчезнет. Если очищать весь <code>click</code>, пострадает соседний код.</p>\n<h2>Почему делегирование переживает замену кнопки</h2>\n<p>Прямой обработчик хранится на конкретной кнопке. Когда код заменяет содержимое контейнера через <code>.html()</code>, старая кнопка удаляется вместе с данными и событиями. Новая кнопка имеет тот же класс, но это другой DOM-узел.</p>\n<p>При делегировании обработчик хранится на контейнере, который остаётся в DOM. Событие от новой кнопки всплывает до контейнера. jQuery проверяет селектор <code>.js-order-submit</code> и вызывает функцию. Если обновляется сам <code>#order-panel</code>, этот корень тоже исчезает. После вставки нового корня mount нужно вызвать для нового узла.</p>\n<h2>Локальная проверка без сервера</h2>\n<p>Пример проверяет только контракт браузерного события. Он не доказывает, что API принимает запрос один раз, не измеряет задержку и не заменяет интеграционный тест.</p>\n<pre><code>var calls = 0;\nvar $panel = $('&lt;div id=order-panel&gt;' +\n '&lt;button class=js-order-submit type=button&gt;Оформить&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$('body').append($panel);\nmountDemo('#order-panel');\nmountDemo('#order-panel');\nmountDemo('#order-panel');\n$panel.find('.js-order-submit').trigger('click');\nconsole.assert(calls === 1, 'Ожидался один вызов, получено: ' + calls);\n$panel.remove();</code></pre>\n<p>Отрицательный путь должен остаться видимым. Если убрать <code>$root.off('.demoOrder')</code>, сценарий даст <code>calls === 3</code>. Если заменить делегирование на прямой обработчик, а затем заменить внутренний HTML, новый элемент не вызовет функцию. Эти результаты разделяют накопление обработчиков и привязку к недолговечному DOM-узлу.</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>Не используйте <code>document</code> как корень по умолчанию. Выберите ближайший стабильный контейнер. Так область поиска и риск пересечения с другими модулями остаются ограниченными.</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.</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>"
}