{ "index": 348, "slug": "editorial-2018-05-practice-legacy-jquery", "title": "Legacy jQuery: как повторно инициализировать виджет без двойной отправки", "excerpt": "Повторный mount jQuery-виджета накапливает обработчики, если код только вызывает .on(). Разбираем namespace, делегирование, границу корня и проверку: три инициализации должны дать один вызов.", "contentHtml": "
Кнопка заказа выглядит исправной, но один физический клик отправляет два запроса. После следующей Ajax-замены разметки их становится три. Пользователь видит один интерфейс, а браузер запускает несколько обработчиков. Цена ошибки — дублированная операция, лишний запрос и состояние, которое серверу приходится разруливать отдельно.
\nВ legacy-интерфейсе причина обычно появляется в момент повторного mount: страница создаёт форму, затем обновляет её через Ajax и снова вызывает ту же функцию. Если функция каждый раз только добавляет обработчик через .on(), старые обработчики остаются. Нам нужен небольшой контракт: один стабильный корень, своё пространство имён и проверка «несколько mount — один клик».
Перед исправлением отделите событие браузера от сетевого запроса. Сначала считайте вызовы обработчика, а не ответы API. Так можно доказать, что ошибка возникла в DOM-коде, и не смешать её с повтором на сервере.
\nfunction mountOrderForm() {\n $('.js-order-submit').on('click', function (event) {\n event.preventDefault();\n sendOrder();\n });\n}\n\nmountOrderForm();\nmountOrderForm(); // один клик вызовет sendOrder дважды\nВ этом примере два вызова выбирают одну и ту же кнопку и добавляют два обработчика. jQuery не считает второй вызов дубликатом только потому, что селектор и тело функции совпадают. При клике оба обработчика выполняют свой код.
\n.on() прикрепляет обработчик к выбранным элементам. Если передать второй аргумент-селектор, обработчик становится делегированным: jQuery слушает событие на выбранном элементе и проверяет подходящих потомков при всплытии. Без селектора обработчик назначается напрямую на текущий набор узлов.
Пространство имён — суффикс после точки, например click.orderForm. Оно не создаёт отдельное дерево событий и не делает имена иерархическими. Это фильтр для последующего .off(): off('.orderForm') на конкретном корне снимает обработчики этого namespace, но не безымянный click соседнего модуля. Поэтому имя должно быть уникальным для владельца виджета.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один клик даёт два запроса | mount повторно вызвал .on() | Поставить счётчик и вызвать mount дважды | Перед .on() выполнить off своего namespace |
| После Ajax новая кнопка молчит | Обработчик был на старом DOM-узле | Сравнить узел до и после замены HTML | Слушать постоянный контейнер через селектор потомка |
| Пропал чужой клик | Очистка выполнена по общему типу события | Найти вызовы off('click') | Использовать конкретный namespace и корень виджета |
| Двойной запрос остался | Есть второй корень или сервер повторяет операцию | Разделить лог событий и сетевых запросов | Оставить одну точку владения и защитить серверную операцию |
Пусть #order-panel — единственный корень виджета, а кнопка отправки имеет класс .js-order-submit. Все события этого модуля получают namespace .orderForm. Тогда повторный mount можно сделать безопасным: сначала очистить только своё пространство имён на одном корне, затем назначить один делегированный обработчик.
(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'), можно снять чужие обработчики.
Прямой обработчик хранится на конкретной кнопке. Когда код заменяет содержимое контейнера через .html(), старая кнопка удаляется вместе с данными и обработчиками. Новая кнопка имеет тот же класс, но это уже другой DOM-узел.
При делегировании обработчик хранится на контейнере, который остаётся в DOM. Событие от новой кнопки всплывает до контейнера, а jQuery проверяет селектор .js-order-submit. Поэтому после замены внутреннего HTML mount не нужен. Если же заменяется сам #order-panel, исчезает и обработчик на корне. После вставки нового корня mount нужно вызвать снова.
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 проверять больше делегированных селекторов на каждом клике.
Тест должен проверять не только положительный путь. Он должен показать, что обработчик не накапливается, переживает замену дочерней кнопки и не снимает событие другого namespace.
\nvar 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, очистка может затронуть кнопку помощи.
.orderForm, а не общий .form.Схема требует jQuery 1.7 или новее, потому что .on() и .off() появились в этой версии. Старые методы .bind(), .live() и .delegate() имеют другой путь миграции. Нельзя механически заменить вызовы, не проверив жизненный цикл разметки.
Namespace действует только там, где вызван .off(). Он не удаляет обработчики с другого корня. Если виджет монтируется одновременно на внешний контейнер и на дочерний блок, один клик может пройти через два обработчика. Оставьте одну точку владения и проверьте её количеством найденных корней.
Делегирование подходит для событий, которые доходят до корня. Оно не исправляет неправильный селектор, остановку всплытия через stopPropagation() или обработчик, который сам вызывает действие дважды. Оно также не делает повторный HTTP-запрос безопасным. Для заказа нужна серверная проверка и, при необходимости, идемпотентный ключ.
Не вставляйте непроверенную строку из формы, URL или cookie через .html(). Это отдельный риск выполнения HTML и скриптов; исправление событийного namespace его не устраняет.
Решение готово, когда один корень проходит тест с тремя вызовами mount, один клик вызывает обработчик ровно один раз, новая кнопка после замены внутреннего HTML тоже вызывает его один раз, а соседний обработчик с другим namespace продолжает работать. Затем отдельно проверяется повторный сетевой запрос и реакция сервера. Учебный счётчик подтверждает только браузерный контракт и не доказывает, что операция на сервере выполнилась один раз.
\n