В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем mountOrderForm, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Цена ошибки — один клик создаёт несколько запросов и может несколько раз изменить состояние на сервере.
Главный вопрос здесь узкий: как написать инициализацию jQuery-виджета так, чтобы её можно было вызвать повторно и на кнопке оставался ровно один наш обработчик? Не будем переписывать весь legacy-код. Достаточно сделать явный контракт у одной функции mount и проверить его в браузере.
Почему обработчик умножается
Метод .on() привязывает обработчик к текущей выбранной коллекции. Если один и тот же код вызвать ещё раз, старый обработчик сам не исчезает. Официальная документация jQuery отдельно отмечает, что один обработчик можно привязать к элементу несколько раз. Поэтому проблема не в Ajax как таковом, а в функции, которая при каждом вызове только добавляет новое событие.
Плохой вариант обычно выглядит безобидно. Его легко не заметить, когда страница открывается один раз и только руками.
function mountOrderForm() {
$('.js-order-submit').on('click', function (event) {
event.preventDefault();
sendOrder();
});
}
mountOrderForm();
mountOrderForm(); // теперь у каждой найденной кнопки два обработчика
Не надо лечить это глобальным off("click"). Такой вызов снимет и события соседнего кода, который может не иметь отношения к форме. Сначала нужно дать событиям нашего виджета собственное имя. В jQuery пространство имён не является иерархией, но позволяет снять обработчики по имени, не трогая чужие click-события.
Контракт функции mount
Для этого примера договоримся о трёх вещах. Контейнер #order-panel существует до вызова функции. Все события виджета получают пространство имён .orderForm. После выполнения функции у контейнера есть ровно один делегированный обработчик для кнопки отправки. Такая формулировка важнее названия функции: по ней можно проверить результат и не спорить о том, достаточно ли «аккуратно» написан код.
| Условие | Действие mount | Ожидаемый результат | Чего не делаем |
|---|---|---|---|
| Контейнер уже есть в DOM | Работаем от #order-panel | Есть стабильная граница виджета | Не ищем кнопку по всему документу |
| mount вызван повторно | Снимаем .orderForm с контейнера | Старый обработчик виджета исчезает | Не вызываем off("click") |
| Кнопка появилась позже | Используем селектор во втором аргументе .on() | Клик новой кнопки доходит до контейнера | Не перепривязываем всё дерево после каждой мелочи |
| Соседний код слушает click | Оставляем чужое пространство имён нетронутым | Другой модуль продолжает работать | Не полагаемся на порядок загрузки скриптов |
Рабочий пример
В коде ниже обработчик висит на постоянном контейнере, а не на самой кнопке. Это небольшое делегирование: jQuery проверит, что событие пришло от потомка с классом .js-order-submit. Подробно о том, почему это полезно при замене разметки, поговорим в следующей заметке; здесь важно другое — перед новым .on() мы удаляем только обработчики нашей зоны.
(function ($) {
var eventNamespace = '.orderForm';
function sendOrder($button) {
// В проекте здесь будет Ajax-вызов или событие в общий слой.
window.console.count('order request');
$button.addClass('is-pending');
}
function mountOrderForm(root) {
var $root = $(root);
if ($root.length !== 1) {
throw new Error('Нужен один контейнер формы заказа');
}
$root.off(eventNamespace);
$root.on('click' + eventNamespace, '.js-order-submit', function (event) {
event.preventDefault();
sendOrder($(this));
});
}
window.mountOrderForm = mountOrderForm;
}(jQuery));
mountOrderForm('#order-panel');
Вызов $root.off(eventNamespace) затрагивает все события с пространством .orderForm на этом контейнере. Это удобно, когда у виджета несколько собственных событий: например, click.orderForm и change.orderForm. Но имя должно быть достаточно конкретным. Если два независимых скрипта выберут одно и то же .form, они начнут снимать события друг друга.
Воспроизводимая проверка без сервера
Не нужно ждать настоящего API, чтобы увидеть дефект. В консоли страницы можно собрать короткий счётчик и трижды вызвать тестовый mount. Если после одного программного клика счётчик равен единице, контракт выполнен. Если он равен трём, проблема остаётся на фронтенде и сервер здесь пока ни при чём.
var calls = 0;
var $panel = $('<div id="order-panel"><a class="js-order-submit" href="#">Оформить</a></div>');
function mountDemo(root) {
var $root = $(root);
$root.off('.demoOrder');
$root.on('click.demoOrder', '.js-order-submit', function (event) {
event.preventDefault();
calls += 1;
});
}
$('body').append($panel);
mountDemo('#order-panel');
mountDemo('#order-panel');
mountDemo('#order-panel');
$panel.find('.js-order-submit').trigger('click');
window.console.assert(calls === 1, 'Нужен один обработчик, получено: ' + calls);
$panel.remove();
В рабочем проекте вместо подмены console.count полезнее вынести обработчик в именованную функцию и проверить количество вызовов тестом. Но даже такой короткий сценарий дисциплинирует: он проверяет не внешний вид кнопки, а свойство инициализации при повторном запуске.
Когда вызывать mount
Я бы вызывал функцию в двух местах: после начальной загрузки страницы и после того кода, который действительно заменил или добавил разметку внутри #order-panel. Не нужно размещать вызов в каждом Ajax-обработчике приложения «на всякий случай». Чем меньше мест создают виджет, тем проще понять, почему он существует на странице.
Если обновление заменяет сам #order-panel, старый контейнер вместе со своими событиями уйдёт из DOM. Тогда нужно передать в mountOrderForm уже новый контейнер после вставки. Если же постоянным остаётся внешний блок, лучше выбрать его корнем и менять только внутреннюю разметку. Это решение не универсально: оно зависит от того, какой узел реально переживает обновление.
Последовательность внедрения
- Найти функцию, которая сейчас повторно вешает события, и назвать один постоянный контейнер виджета.
- Выбрать уникальное пространство имён, например
.orderFormили.cartItem, а не общее.click. - Перед каждым назначением вызвать
offтолько для этого пространства имён на выбранном контейнере. - Назначить обработчик через
onи, если кнопки меняются, передать селектор потомка. - Трижды вызвать mount и одним кликом подтвердить, что полезное действие срабатывает один раз.
- Отдельно проверить реальный серверный сценарий: клиентская защита не должна быть единственным барьером повторной операции.
Ограничения
- Этот приём требует jQuery 1.7 или новее, потому что использует
.on()и.off(). Если проект закреплён на более старой версии, сначала надо зафиксировать допустимый путь обновления или отдельный совместимый адаптер. - Пространство имён защищает только события в браузере. Оно не отменяет уже отправленный запрос и не делает серверную операцию безопасной при повторе страницы, таймауте или ручном запросе.
- Делегирование работает для событий, которые доходят до выбранного предка. Для особых типов событий и SVG у jQuery есть ограничения; их надо проверять по документации, а не переносить этот шаблон вслепую.
- Если виджет начинает управлять десятком независимых состояний, одного обработчика уже мало. Сначала стоит разделить маленькие функции, а не превращать
mountOrderFormв глобальный диспетчер.
Итог
Повторная инициализация не обязана быть опасной. Ей нужен простой договор: устойчивый корень, собственное пространство имён и проверка «несколько mount — один клик». Этот договор легко показать коллеге, а при следующей Ajax-правке не придётся угадывать, сколько обработчиков уже живёт на кнопке.
Проверяемые источники
- jQuery API: .on() — прямая и делегированная привязка, пространства имён, повторная привязка и ограничения делегирования
- jQuery API: .off() — снятие обработчика по типу события, селектору и пространству имён