{ "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 — один клик».
.on() прикрепляет функцию к выбранным DOM-узлам. Повторный вызов с тем же селектором не распознаёт прежнюю функцию как дубликат. Он добавляет ещё одну. При клике браузер запускает обе функции. Если каждая вызывает отправку формы или Ajax, действие повторяется.
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.
В DOM существует один контейнер #order-panel. Кнопка отправки имеет класс .js-order-submit. Все события виджета получают namespace .orderForm. После каждого успешного вызова mount контейнер содержит один обработчик этого виджета. Это проверяется поведением: после трёх mount один программный клик увеличивает счётчик на единицу.
Namespace — часть имени события, например click.orderForm. Он не создаёт отдельное дерево. Он даёт фильтр для .off(). Поэтому off('.orderForm') удаляет события виджета на выбранном объекте, но не снимает безымянный click соседнего модуля.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один клик даёт два запроса | mount повторно вызвал .on() | Поставить счётчик и вызвать mount дважды | Перед .on() выполнить off своего namespace |
| После Ajax новая кнопка молчит | Обработчик был на старом DOM-узле | Сравнить узел до и после замены HTML | Слушать постоянный контейнер через селектор потомка |
| Пропал чужой клик | Очистка выполнена по общему типу события | Найти вызовы off('click') | Использовать конкретный namespace и корень виджета |
| Двойной запрос остался | Виджет монтируется на разных корнях или сервер повторяет операцию | Разделить логи событий и сетевых запросов | Оставить один корень и защитить серверную операцию |
Постоянный контейнер принимает событие от кнопки через делегирование. Селектор потомка передаётся вторым аргументом .on(). Поэтому обработчик не нужно назначать заново, когда кнопка появляется внутри контейнера.
(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, пострадает соседний код.
Прямой обработчик хранится на конкретной кнопке. Когда код заменяет содержимое контейнера через .html(), старая кнопка удаляется вместе с данными и событиями. Новая кнопка имеет тот же класс, но это другой DOM-узел.
При делегировании обработчик хранится на контейнере, который остаётся в DOM. Событие от новой кнопки всплывает до контейнера. jQuery проверяет селектор .js-order-submit и вызывает функцию. Если обновляется сам #order-panel, этот корень тоже исчезает. После вставки нового корня mount нужно вызвать для нового узла.
Пример проверяет только контракт браузерного события. Он не доказывает, что API принимает запрос один раз, не измеряет задержку и не заменяет интеграционный тест.
\nvar 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-узлу.
.orderForm, а не общий .form.Схема требует jQuery 1.7 или новее, потому что использует .on() и .off(). Старые методы .bind(), .live() и .delegate() имеют другой путь миграции. Нельзя механически заменить вызовы, не проверив жизненный цикл разметки.
Namespace действует только там, где вызван .off(). Он не удаляет обработчики с другого корня. Если виджет монтируется на внешний контейнер и на дочерний блок, один клик может пройти через два обработчика. Оставьте одну точку владения.
Делегирование подходит для событий, которые доходят до корня. Оно не исправляет неправильный селектор, остановку всплытия через stopPropagation() или обработчик, который сам вызывает действие дважды. Оно не делает повторный HTTP-запрос безопасным. Для заказа нужна серверная проверка и, при необходимости, идемпотентный ключ.
Не используйте document как корень по умолчанию. Выберите ближайший стабильный контейнер. Так область поиска и риск пересечения с другими модулями остаются ограниченными.
Работу можно считать готовой, когда один корень проходит проверку с тремя вызовами mount, один клик вызывает обработчик ровно один раз, новая кнопка после замены внутреннего HTML тоже вызывает его один раз, а соседний обработчик с другим namespace продолжает работать. Затем отдельно проверяется серверный сценарий повторного запроса. Учебный счётчик подтверждает только браузерный контракт.
\n