From 213fcf6ab154f098283c049c677e2d39970ab3e2 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 4 Sep 2026 01:03:21 +0300 Subject: [PATCH] Editorial: polish legacy jQuery rewrite 348 --- editorial/agent-rewrites/348.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/editorial/agent-rewrites/348.json b/editorial/agent-rewrites/348.json index 5bb78a2..e060bf1 100644 --- a/editorial/agent-rewrites/348.json +++ b/editorial/agent-rewrites/348.json @@ -1,7 +1,7 @@ { "index": 348, "slug": "editorial-2018-05-practice-legacy-jquery", - "title": "Legacy jQuery: как повторно инициализировать виджет без двойного клика", - "excerpt": "Повторный mount jQuery-виджета накапливает обработчики, если код только вызывает .on(). Разбираем namespace, делегирование, отрицательный путь и проверку: три инициализации должны дать один вызов.", - "contentHtml": "

Кнопка заказа выглядит исправной, но один клик отправляет два запроса. После следующей Ajax-замены разметки — три. Пользователь видит один интерфейс, а браузер выполняет несколько обработчиков. Цена ошибки — дублированная операция, лишний запрос и состояние, которое серверу приходится разруливать отдельно.

\n

В legacy-интерфейсе это начинается с безобидного вызова mountOrderForm(). Страница загружает форму, затем код обновляет её через Ajax и вызывает тот же mount ещё раз. Если функция каждый раз только добавляет обработчик через .on(), старые обработчики не исчезают. Нужен контракт: постоянный корень виджета, уникальное пространство имён и проверка «несколько mount — один клик».

\n

Симптом и механизм

\n

.on() прикрепляет функцию к выбранным DOM-узлам. Повторный вызов с тем же селектором не распознаёт прежнюю функцию как дубликат. Он добавляет ещё одну. При клике браузер запускает обе функции. Если каждая вызывает отправку формы или Ajax, действие повторяется.

\n
function mountOrderForm() {\n  $('.js-order-submit').on('click', function (event) {\n    event.preventDefault();\n    sendOrder();\n  });\n}\n\nmountOrderForm();\nmountOrderForm(); // один клик вызовет sendOrder дважды
\n

Глобальный $(document).off('click') не является исправлением. Он может снять обработчики другого модуля. Граница очистки должна совпадать с границей владения. Если виджет владеет #order-panel, очищайте его события и только его namespace.

\n
Повторная инициализация jQuery-виджета через очистку namespace и делегированный обработчик
Mount сначала снимает события своего пространства имён, затем назначает один делегированный обработчик на устойчивый контейнер.
\n

Контракт повторной инициализации

\n

В DOM существует один контейнер #order-panel. Кнопка отправки имеет класс .js-order-submit. Все события виджета получают namespace .orderForm. После каждого успешного вызова mount контейнер содержит один обработчик этого виджета. Это проверяется поведением: после трёх mount один программный клик увеличивает счётчик на единицу.

\n

Namespace — часть имени события, например click.orderForm. Он не создаёт отдельное дерево. Он даёт фильтр для .off(). Поэтому off('.orderForm') удаляет события виджета на выбранном объекте, но не снимает безымянный click соседнего модуля.

\n
СимптомПричинаПроверкаДействие
Один клик даёт два запросаmount повторно вызвал .on()Поставить счётчик и вызвать mount дваждыПеред .on() выполнить off своего namespace
После Ajax новая кнопка молчитОбработчик был на старом DOM-узлеСравнить узел до и после замены HTMLСлушать постоянный контейнер через селектор потомка
Пропал чужой кликОчистка выполнена по общему типу событияНайти вызовы off('click')Использовать конкретный namespace и корень виджета
Двойной запрос осталсяВиджет монтируется на разных корнях или сервер повторяет операциюРазделить логи событий и сетевых запросовОставить один корень и защитить серверную операцию
\n

Рабочая схема

\n

Постоянный контейнер принимает событие от кнопки через делегирование. Селектор потомка передаётся вторым аргументом .on(). Поэтому обработчик не нужно назначать заново, когда кнопка появляется внутри контейнера.

\n
(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');
\n

Сначала проверяется количество корней. Затем off(namespace) очищает события на одном корне. После этого on('click' + namespace, selector, handler) создаёт один делегированный обработчик. Порядок важен. Если очищать namespace после .on(), новый обработчик тоже исчезнет. Если очищать весь click, пострадает соседний код.

\n

Почему делегирование переживает замену кнопки

\n

Прямой обработчик хранится на конкретной кнопке. Когда код заменяет содержимое контейнера через .html(), старая кнопка удаляется вместе с данными и событиями. Новая кнопка имеет тот же класс, но это другой DOM-узел.

\n

При делегировании обработчик хранится на контейнере, который остаётся в DOM. Событие от новой кнопки всплывает до контейнера. jQuery проверяет селектор .js-order-submit и вызывает функцию. Если обновляется сам #order-panel, этот корень тоже исчезает. После вставки нового корня mount нужно вызвать для нового узла.

\n

Локальная проверка без сервера

\n

Пример проверяет только контракт браузерного события. Он не доказывает, что API принимает запрос один раз, не измеряет задержку и не заменяет интеграционный тест.

\n
var calls = 0;\nvar $panel = $('<div id=order-panel>' +\n  '<button class=js-order-submit 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$('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();
\n

Отрицательный путь должен остаться видимым. Если убрать $root.off('.demoOrder'), сценарий даст calls === 3. Если заменить делегирование на прямой обработчик, а затем заменить внутренний HTML, новый элемент не вызовет функцию. Эти результаты разделяют накопление обработчиков и привязку к недолговечному DOM-узлу.

\n

Порядок внедрения

\n
  1. Найдите функцию, которая вызывается после загрузки и после Ajax-обновления. Зафиксируйте узел, переживающий замену.
  2. Выберите один корень и проверьте, что селектор находит ровно один элемент в момент mount.
  3. Назначьте уникальный namespace, например .orderForm, а не общий .form.
  4. Перед назначением удаляйте только этот namespace на выбранном корне.
  5. Если дочерние кнопки создаются заново, используйте делегирование через селектор потомка.
  6. Вызовите mount три раза, выполните один клик, замените внутренний HTML и повторите клик.
  7. Отдельно проверьте сетевой сценарий и серверную защиту от повторной операции.
\n

Ограничения и отрицательный путь

\n

Схема требует jQuery 1.7 или новее, потому что использует .on() и .off(). Старые методы .bind(), .live() и .delegate() имеют другой путь миграции. Нельзя механически заменить вызовы, не проверив жизненный цикл разметки.

\n

Namespace действует только там, где вызван .off(). Он не удаляет обработчики с другого корня. Если виджет монтируется на внешний контейнер и на дочерний блок, один клик может пройти через два обработчика. Оставьте одну точку владения.

\n

Делегирование подходит для событий, которые доходят до корня. Оно не исправляет неправильный селектор, остановку всплытия через stopPropagation() или обработчик, который сам вызывает действие дважды. Оно не делает повторный HTTP-запрос безопасным. Для заказа нужна серверная проверка и, при необходимости, идемпотентный ключ.

\n

Не используйте document как корень по умолчанию. Выберите ближайший стабильный контейнер. Так область поиска и риск пересечения с другими модулями остаются ограниченными.

\n

Проверяемый критерий готовности

\n

Работу можно считать готовой, когда один корень проходит проверку с тремя вызовами mount, один клик вызывает обработчик ровно один раз, новая кнопка после замены внутреннего HTML тоже вызывает его один раз, а соседний обработчик с другим namespace продолжает работать. Затем отдельно проверяется серверный сценарий повторного запроса. Учебный счётчик подтверждает только браузерный контракт.

\n

Проверяемые источники

\n" + "title": "Legacy jQuery: как повторно инициализировать виджет без двойной отправки", + "excerpt": "Повторный mount jQuery-виджета накапливает обработчики, если код только вызывает .on(). Разбираем namespace, делегирование, границу корня и проверку: три инициализации должны дать один вызов.", + "contentHtml": "

Кнопка заказа выглядит исправной, но один физический клик отправляет два запроса. После следующей Ajax-замены разметки их становится три. Пользователь видит один интерфейс, а браузер запускает несколько обработчиков. Цена ошибки — дублированная операция, лишний запрос и состояние, которое серверу приходится разруливать отдельно.

\n

В legacy-интерфейсе причина обычно появляется в момент повторного mount: страница создаёт форму, затем обновляет её через Ajax и снова вызывает ту же функцию. Если функция каждый раз только добавляет обработчик через .on(), старые обработчики остаются. Нам нужен небольшой контракт: один стабильный корень, своё пространство имён и проверка «несколько mount — один клик».

\n

Сначала фиксируем воспроизведение

\n

Перед исправлением отделите событие браузера от сетевого запроса. Сначала считайте вызовы обработчика, а не ответы API. Так можно доказать, что ошибка возникла в DOM-коде, и не смешать её с повтором на сервере.

\n
function mountOrderForm() {\n  $('.js-order-submit').on('click', function (event) {\n    event.preventDefault();\n    sendOrder();\n  });\n}\n\nmountOrderForm();\nmountOrderForm(); // один клик вызовет sendOrder дважды
\n

В этом примере два вызова выбирают одну и ту же кнопку и добавляют два обработчика. jQuery не считает второй вызов дубликатом только потому, что селектор и тело функции совпадают. При клике оба обработчика выполняют свой код.

\n
\"Три
Mount сначала снимает события своего пространства имён, затем назначает один делегированный обработчик на устойчивый контейнер.
\n

Что именно хранит jQuery

\n

.on() прикрепляет обработчик к выбранным элементам. Если передать второй аргумент-селектор, обработчик становится делегированным: jQuery слушает событие на выбранном элементе и проверяет подходящих потомков при всплытии. Без селектора обработчик назначается напрямую на текущий набор узлов.

\n

Пространство имён — суффикс после точки, например click.orderForm. Оно не создаёт отдельное дерево событий и не делает имена иерархическими. Это фильтр для последующего .off(): off('.orderForm') на конкретном корне снимает обработчики этого namespace, но не безымянный click соседнего модуля. Поэтому имя должно быть уникальным для владельца виджета.

\n
СимптомПричинаПроверкаДействие
Один клик даёт два запросаmount повторно вызвал .on()Поставить счётчик и вызвать mount дваждыПеред .on() выполнить off своего namespace
После Ajax новая кнопка молчитОбработчик был на старом DOM-узлеСравнить узел до и после замены HTMLСлушать постоянный контейнер через селектор потомка
Пропал чужой кликОчистка выполнена по общему типу событияНайти вызовы off('click')Использовать конкретный namespace и корень виджета
Двойной запрос осталсяЕсть второй корень или сервер повторяет операциюРазделить лог событий и сетевых запросовОставить одну точку владения и защитить серверную операцию
\n

Идемпотентный mount

\n

Пусть #order-panel — единственный корень виджета, а кнопка отправки имеет класс .js-order-submit. Все события этого модуля получают namespace .orderForm. Тогда повторный mount можно сделать безопасным: сначала очистить только своё пространство имён на одном корне, затем назначить один делегированный обработчик.

\n
(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');
\n

Проверка количества корней должна происходить до изменения событий. Затем off(namespace) действует только на выбранный контейнер, а on('click' + namespace, selector, handler) оставляет один делегированный обработчик. Порядок важен: если вызвать off после on, только что созданный обработчик тоже исчезнет. Если вызвать off('click'), можно снять чужие обработчики.

\n

Граница между внутренним HTML и корнем

\n

Прямой обработчик хранится на конкретной кнопке. Когда код заменяет содержимое контейнера через .html(), старая кнопка удаляется вместе с данными и обработчиками. Новая кнопка имеет тот же класс, но это уже другой DOM-узел.

\n

При делегировании обработчик хранится на контейнере, который остаётся в DOM. Событие от новой кнопки всплывает до контейнера, а jQuery проверяет селектор .js-order-submit. Поэтому после замены внутреннего HTML mount не нужен. Если же заменяется сам #order-panel, исчезает и обработчик на корне. После вставки нового корня mount нужно вызвать снова.

\n
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// обработчик нужно назначить на новый корень.
\n

Выбирайте ближайший стабильный контейнер, а не document по умолчанию. Большое глобальное дерево усложняет поиск владельца события и заставляет jQuery проверять больше делегированных селекторов на каждом клике.

\n

Проверка: три mount, две кнопки и соседний модуль

\n

Тест должен проверять не только положительный путь. Он должен показать, что обработчик не накапливается, переживает замену дочерней кнопки и не снимает событие другого namespace.

\n
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('');\n$panel.find('.js-order-submit').trigger('click');\nconsole.assert(calls === 2, 'Новая кнопка должна вызвать обработчик один раз');\n$panel.remove();
\n

Ожидаемый результат — calls === 2 после двух разных кнопок и helpCalls === 1. Если убрать $root.off('.demoOrder'), первый счётчик станет равен трём. Если заменить делегирование на прямую привязку к кнопке, клик после .html() не будет обработан. Если заменить namespace на общий click, очистка может затронуть кнопку помощи.

\n

Порядок внедрения

\n
  1. Найдите все места, где функция вызывается после загрузки и после Ajax-обновления. Зафиксируйте, какой узел переживает замену.
  2. Выберите один корень и проверьте, что селектор находит ровно один элемент в момент mount.
  3. Назначьте уникальный namespace, например .orderForm, а не общий .form.
  4. Перед назначением удаляйте только этот namespace на выбранном корне.
  5. Если дочерние кнопки создаются заново, используйте делегирование через селектор потомка.
  6. Вызовите mount три раза, выполните клик, замените внутренний HTML и повторите клик.
  7. Отдельно проверьте сетевой сценарий и серверную защиту от повторной операции.
\n

Границы и отрицательный путь

\n

Схема требует jQuery 1.7 или новее, потому что .on() и .off() появились в этой версии. Старые методы .bind(), .live() и .delegate() имеют другой путь миграции. Нельзя механически заменить вызовы, не проверив жизненный цикл разметки.

\n

Namespace действует только там, где вызван .off(). Он не удаляет обработчики с другого корня. Если виджет монтируется одновременно на внешний контейнер и на дочерний блок, один клик может пройти через два обработчика. Оставьте одну точку владения и проверьте её количеством найденных корней.

\n

Делегирование подходит для событий, которые доходят до корня. Оно не исправляет неправильный селектор, остановку всплытия через stopPropagation() или обработчик, который сам вызывает действие дважды. Оно также не делает повторный HTTP-запрос безопасным. Для заказа нужна серверная проверка и, при необходимости, идемпотентный ключ.

\n

Не вставляйте непроверенную строку из формы, URL или cookie через .html(). Это отдельный риск выполнения HTML и скриптов; исправление событийного namespace его не устраняет.

\n

Критерий готовности

\n

Решение готово, когда один корень проходит тест с тремя вызовами mount, один клик вызывает обработчик ровно один раз, новая кнопка после замены внутреннего HTML тоже вызывает его один раз, а соседний обработчик с другим namespace продолжает работать. Затем отдельно проверяется повторный сетевой запрос и реакция сервера. Учебный счётчик подтверждает только браузерный контракт и не доказывает, что операция на сервере выполнилась один раз.

\n

Проверяемые источники

\n" }