8 lines
15 KiB
JSON
8 lines
15 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-интерфейсе это начинается с безобидного вызова <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 = $('<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();</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>"
|
||
}
|