559 lines
51 KiB
JavaScript
559 lines
51 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function paragraph(text) {
|
||
return '<p>' + text + '</p>';
|
||
}
|
||
|
||
function heading(text) {
|
||
return '<h2>' + text + '</h2>';
|
||
}
|
||
|
||
function codeBlock(code) {
|
||
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
|
||
}
|
||
|
||
function figure(src, alt, caption) {
|
||
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
|
||
}
|
||
|
||
function orderedList(items) {
|
||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
}
|
||
|
||
function bulletList(items) {
|
||
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function dataTable(headers, rows) {
|
||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
|
||
}
|
||
|
||
function sourceList(items) {
|
||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function visibleText(html) {
|
||
return html
|
||
.replace(/<[^>]*>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function createRevision(meta, bodyParts, sources) {
|
||
const bodyHtml = bodyParts.join('\n');
|
||
const bodyLength = visibleText(bodyHtml).length;
|
||
|
||
if (bodyLength < 5000 || bodyLength > 15000) {
|
||
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
|
||
}
|
||
|
||
const contentHtml = [
|
||
bodyHtml,
|
||
heading('Проверяемые источники'),
|
||
sourceList(sources),
|
||
].join('\n');
|
||
|
||
for (const requiredFragment of ['<figure>', '<table>', '<pre><code>']) {
|
||
if (!contentHtml.includes(requiredFragment)) {
|
||
throw new Error(meta.slug + ': missing required fragment ' + requiredFragment);
|
||
}
|
||
}
|
||
|
||
if (sources.length < 2) {
|
||
throw new Error(meta.slug + ': at least two primary sources are required');
|
||
}
|
||
|
||
return {
|
||
...meta,
|
||
contentHtml,
|
||
bodyLength,
|
||
};
|
||
}
|
||
|
||
const jqueryOn = {
|
||
title: 'jQuery API: .on()',
|
||
url: 'https://api.jquery.com/on/',
|
||
note: 'прямая и делегированная привязка, пространства имён, повторная привязка и ограничения делегирования',
|
||
};
|
||
|
||
const jqueryOff = {
|
||
title: 'jQuery API: .off()',
|
||
url: 'https://api.jquery.com/off/',
|
||
note: 'снятие обработчика по типу события, селектору и пространству имён',
|
||
};
|
||
|
||
const jqueryHtml = {
|
||
title: 'jQuery API: .html()',
|
||
url: 'https://api.jquery.com/html/',
|
||
note: 'замена содержимого, удаление событий дочерних узлов и риск вставки непроверенной HTML-строки',
|
||
};
|
||
|
||
const jqueryAjax = {
|
||
title: 'jQuery API: jQuery.ajax()',
|
||
url: 'https://api.jquery.com/jQuery.ajax/',
|
||
note: 'jqXHR, обработчики done/fail/always, timeout и порядок завершения запроса',
|
||
};
|
||
|
||
const jquerySerialize = {
|
||
title: 'jQuery API: .serialize()',
|
||
url: 'https://api.jquery.com/serialize/',
|
||
note: 'какие поля формы попадают в URL-кодированную строку и почему файлы в неё не входят',
|
||
};
|
||
|
||
const jqueryProp = {
|
||
title: 'jQuery API: .prop()',
|
||
url: 'https://api.jquery.com/prop/',
|
||
note: 'динамические свойства disabled и checked в jQuery 1.6+',
|
||
};
|
||
|
||
const jqueryData = {
|
||
title: 'jQuery API: .data()',
|
||
url: 'https://api.jquery.com/data/',
|
||
note: 'хранение состояния рядом с DOM-узлом',
|
||
};
|
||
|
||
const jqueryRemoveData = {
|
||
title: 'jQuery API: .removeData()',
|
||
url: 'https://api.jquery.com/removeData/',
|
||
note: 'удаление ранее сохранённого значения из внутреннего хранилища jQuery',
|
||
};
|
||
|
||
const jqueryAlways = {
|
||
title: 'jQuery API: deferred.always()',
|
||
url: 'https://api.jquery.com/deferred.always/',
|
||
note: 'обработчик, который вызывается и после resolve, и после reject; подходит для освобождения интерфейса',
|
||
};
|
||
|
||
const practiceArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-05-practice-legacy-jquery',
|
||
title: 'jQuery. Как повторно инициализировать виджет и не получить два клика',
|
||
categories: ['JavaScript', 'jQuery', 'Практика'],
|
||
cover: '/assets/editorial/2018/jquery-reinit-namespaces.svg',
|
||
excerpt: 'Разбираем маленький контракт для legacy-виджета: повторный mount снимает только свои события, назначает один обработчик и проверяется тремя вызовами подряд.',
|
||
readingMinutes: 9,
|
||
},
|
||
[
|
||
paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем <code>mountOrderForm</code>, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Ошибка неприятна не из-за консоли: одна пользовательская команда может несколько раз изменить состояние на сервере.'),
|
||
paragraph('Главный вопрос здесь узкий: как написать инициализацию jQuery-виджета так, чтобы её можно было вызвать повторно и на кнопке оставался ровно один наш обработчик? Не будем переписывать весь legacy-код. Достаточно сделать явный контракт у одной функции <code>mount</code> и проверить его в браузере.'),
|
||
heading('Почему обработчик умножается'),
|
||
paragraph('Метод <code>.on()</code> привязывает обработчик к текущей выбранной коллекции. Если один и тот же код вызвать ещё раз, старый обработчик сам не исчезает. Официальная документация jQuery отдельно отмечает, что один обработчик можно привязать к элементу несколько раз. Поэтому проблема не в Ajax как таковом, а в функции, которая при каждом вызове только добавляет новое событие.'),
|
||
paragraph('Плохой вариант обычно выглядит безобидно. Его легко не заметить, когда страница открывается один раз и только руками.'),
|
||
codeBlock(String.raw`
|
||
function mountOrderForm() {
|
||
$('.js-order-submit').on('click', function (event) {
|
||
event.preventDefault();
|
||
sendOrder();
|
||
});
|
||
}
|
||
|
||
mountOrderForm();
|
||
mountOrderForm(); // теперь у каждой найденной кнопки два обработчика
|
||
`),
|
||
paragraph('Не надо лечить это глобальным <code>off("click")</code>. Такой вызов снимет и события соседнего кода, который может не иметь отношения к форме. Сначала нужно дать событиям нашего виджета собственное имя. В jQuery пространство имён не является иерархией, но позволяет снять обработчики по имени, не трогая чужие <code>click</code>-события.'),
|
||
figure('/assets/editorial/2018/jquery-reinit-namespaces.svg', 'Три шага повторной инициализации jQuery-виджета: снять обработчики с пространством имён, затем назначить один новый', 'Повторный вызов mount сначала очищает только события конкретного виджета, затем создаёт один обработчик.'),
|
||
heading('Контракт функции mount'),
|
||
paragraph('Для этого примера договоримся о трёх вещах. Контейнер <code>#order-panel</code> существует до вызова функции. Все события виджета получают пространство имён <code>.orderForm</code>. После выполнения функции у контейнера есть ровно один делегированный обработчик для кнопки отправки. Такая формулировка важнее названия функции: по ней можно проверить результат и не спорить о том, достаточно ли «аккуратно» написан код.'),
|
||
dataTable(
|
||
['Условие', 'Действие mount', 'Ожидаемый результат', 'Чего не делаем'],
|
||
[
|
||
['Контейнер уже есть в DOM', 'Работаем от <code>#order-panel</code>', 'Есть стабильная граница виджета', 'Не ищем кнопку по всему документу'],
|
||
['mount вызван повторно', 'Снимаем <code>.orderForm</code> с контейнера', 'Старый обработчик виджета исчезает', 'Не вызываем <code>off("click")</code>'],
|
||
['Кнопка появилась позже', 'Используем селектор во втором аргументе <code>.on()</code>', 'Клик новой кнопки доходит до контейнера', 'Не перепривязываем всё дерево после каждой мелочи'],
|
||
['Соседний код слушает click', 'Оставляем чужое пространство имён нетронутым', 'Другой модуль продолжает работать', 'Не полагаемся на порядок загрузки скриптов'],
|
||
],
|
||
),
|
||
heading('Рабочий пример'),
|
||
paragraph('В коде ниже обработчик висит на постоянном контейнере, а не на самой кнопке. Это небольшое делегирование: jQuery проверит, что событие пришло от потомка с классом <code>.js-order-submit</code>. Подробно о том, почему это полезно при замене разметки, поговорим в следующей заметке; здесь важно другое — перед новым <code>.on()</code> мы удаляем только обработчики нашей зоны.'),
|
||
codeBlock(String.raw`
|
||
(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');
|
||
`),
|
||
paragraph('Вызов <code>$root.off(eventNamespace)</code> затрагивает все события с пространством <code>.orderForm</code> на этом контейнере. Это удобно, когда у виджета несколько собственных событий: например, <code>click.orderForm</code> и <code>change.orderForm</code>. Но имя должно быть достаточно конкретным. Если два независимых скрипта выберут одно и то же <code>.form</code>, они начнут снимать события друг друга.'),
|
||
heading('Воспроизводимая проверка без сервера'),
|
||
paragraph('Не нужно ждать настоящего API, чтобы увидеть дефект. В консоли страницы можно собрать короткий счётчик и трижды вызвать тестовый mount. Если после одного программного клика счётчик равен единице, контракт выполнен. Если он равен трём, проблема остаётся на фронтенде и сервер здесь пока ни при чём.'),
|
||
codeBlock(String.raw`
|
||
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();
|
||
`),
|
||
paragraph('В рабочем проекте вместо подмены <code>console.count</code> полезнее вынести обработчик в именованную функцию и проверить количество вызовов тестом. Но даже такой короткий сценарий дисциплинирует: он проверяет не внешний вид кнопки, а свойство инициализации при повторном запуске.'),
|
||
heading('Когда вызывать mount'),
|
||
paragraph('Я бы вызывал функцию в двух местах: после начальной загрузки страницы и после того кода, который действительно заменил или добавил разметку внутри <code>#order-panel</code>. Не нужно размещать вызов в каждом Ajax-обработчике приложения «на всякий случай». Чем меньше мест создают виджет, тем проще понять, почему он существует на странице.'),
|
||
paragraph('Если обновление заменяет сам <code>#order-panel</code>, старый контейнер вместе со своими событиями уйдёт из DOM. Тогда нужно передать в <code>mountOrderForm</code> уже новый контейнер после вставки. Если же постоянным остаётся внешний блок, лучше выбрать его корнем и менять только внутреннюю разметку. Это решение не универсально: оно зависит от того, какой узел реально переживает обновление.'),
|
||
heading('Последовательность внедрения'),
|
||
orderedList([
|
||
'Найти функцию, которая сейчас повторно вешает события, и назвать один постоянный контейнер виджета.',
|
||
'Выбрать уникальное пространство имён, например <code>.orderForm</code> или <code>.cartItem</code>, а не общее <code>.click</code>.',
|
||
'Перед каждым назначением вызвать <code>off</code> только для этого пространства имён на выбранном контейнере.',
|
||
'Назначить обработчик через <code>on</code> и, если кнопки меняются, передать селектор потомка.',
|
||
'Трижды вызвать mount и одним кликом подтвердить, что полезное действие срабатывает один раз.',
|
||
'Отдельно проверить реальный серверный сценарий: клиентская защита не должна быть единственным барьером повторной операции.',
|
||
]),
|
||
heading('Ограничения'),
|
||
bulletList([
|
||
'Этот приём требует jQuery 1.7 или новее, потому что использует <code>.on()</code> и <code>.off()</code>. Если проект закреплён на более старой версии, сначала надо зафиксировать допустимый путь обновления или отдельный совместимый адаптер.',
|
||
'Пространство имён защищает только события в браузере. Оно не отменяет уже отправленный запрос и не делает серверную операцию безопасной при повторе страницы, таймауте или ручном запросе.',
|
||
'Делегирование работает для событий, которые доходят до выбранного предка. Для особых типов событий и SVG у jQuery есть ограничения; их надо проверять по документации, а не переносить этот шаблон вслепую.',
|
||
'Если виджет начинает управлять десятком независимых состояний, одного обработчика уже мало. Сначала стоит разделить маленькие функции, а не превращать <code>mountOrderForm</code> в глобальный диспетчер.',
|
||
]),
|
||
heading('Итог'),
|
||
paragraph('Повторная инициализация не обязана быть опасной. Ей нужен простой договор: устойчивый корень, собственное пространство имён и проверка «несколько mount — один клик». Этот договор легко показать коллеге, а при следующей Ajax-правке не придётся угадывать, сколько обработчиков уже живёт на кнопке.'),
|
||
],
|
||
[jqueryOn, jqueryOff],
|
||
);
|
||
|
||
const mechanismArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-05-mechanism-legacy-jquery',
|
||
title: 'jQuery. Почему кнопка перестаёт работать после .html()',
|
||
categories: ['JavaScript', 'jQuery', 'DOM'],
|
||
cover: '/assets/editorial/2018/jquery-delegation-after-html.svg',
|
||
excerpt: 'Разбираем, почему прямой обработчик исчезает вместе с заменённой разметкой, как выбрать устойчивый контейнер для делегирования и где этот приём не подходит.',
|
||
readingMinutes: 9,
|
||
},
|
||
[
|
||
paragraph('Каталог отрисовал новую страницу товаров через Ajax: <code>#products</code> получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться.'),
|
||
paragraph('Главный вопрос этой заметки: почему обработчик пропадает после <code>.html()</code> и как выбрать делегирование так, чтобы оно пережило замену карточек? Здесь важно не запомнить «вешай всё на document», а увидеть, на каком DOM-узле реально хранится обработчик и какой узел переживает обновление.'),
|
||
heading('Что делает .html() с прежней разметкой'),
|
||
paragraph('Когда <code>.html(строка)</code> задаёт новое содержимое, jQuery полностью заменяет прежних потомков контейнера. Документация отдельно предупреждает: перед заменой jQuery удаляет из дочерних элементов данные и обработчики событий. Поэтому прямой click на старой кнопке не «ломается» — он остаётся на старом DOM-узле, которого больше нет. Новая кнопка похожа внешне, но для браузера это другой объект.'),
|
||
paragraph('В этом легко убедиться на коротком примере. Сначала обработчик привязан непосредственно к найденной кнопке. После замены HTML в контейнере новая кнопка появляется без этого обработчика.'),
|
||
codeBlock(String.raw`
|
||
var $products = $('#products');
|
||
|
||
function buy(event) {
|
||
event.preventDefault();
|
||
window.console.log('Товар добавлен');
|
||
}
|
||
|
||
$products.find('.js-buy').on('click', buy);
|
||
|
||
$products.html('<a class="js-buy" href="/cart/add/17">Купить</a>');
|
||
|
||
// Эта новая ссылка создана после .on(), поэтому buy для неё не назначен.
|
||
$products.find('.js-buy').trigger('click');
|
||
`),
|
||
paragraph('Это не повод каждый раз обходить все кнопки после рендера. Прямая привязка нормальна, когда элемент стабилен и событие относится только к нему. Но в списке, который полностью перерисовывается, она делает жизненный цикл события зависимым от каждой вставки HTML. Такую зависимость лучше перенести на постоянный контейнер.'),
|
||
figure('/assets/editorial/2018/jquery-delegation-after-html.svg', 'Сравнение прямого обработчика на кнопке и делегированного обработчика на устойчивом контейнере после замены HTML', 'Прямой обработчик уходит вместе со старой кнопкой. Делегированный остаётся на контейнере и получает клик от новой дочерней кнопки.'),
|
||
heading('Прямая привязка и делегирование — это разные владельцы'),
|
||
paragraph('У <code>.on()</code> без селектора обработчик привязан к текущему набору элементов. Если передать селектор вторым аргументом, обработчик остаётся на выбранном предке и вызывается, когда событие всплывает от подходящего потомка. Документация jQuery называет эти варианты direct и delegated. Для нашего каталога владелец события должен быть не карточкой, а <code>#products</code>, если этот блок не заменяется целиком.'),
|
||
dataTable(
|
||
['Подход', 'Где хранится обработчик', 'Что случится после .html()', 'Подходит для'],
|
||
[
|
||
['Прямой <code>$(".js-buy").on(...)</code>', 'На найденных кнопках', 'Старые узлы удалены, новым кнопкам нужен новый bind', 'Стабильная одиночная кнопка или плагин, которому нужен именно элемент'],
|
||
['Делегированный <code>$root.on(..., ".js-buy", ...)</code>', 'На постоянном <code>$root</code>', 'Новая кнопка под тем же корнем начинает работать сразу', 'Карточки, строки таблицы, пункты меню, которые заменяются'],
|
||
['На <code>document</code>', 'На самом верхнем доступном узле', 'Технически может пережить почти любую замену', 'Только когда ближнего постоянного контейнера действительно нет'],
|
||
],
|
||
),
|
||
heading('Исправление на устойчивом контейнере'),
|
||
paragraph('Выберем ближайший узел, который существует до и после обновления списка. Здесь это <code>#products</code>. Перед назначением снимем только своё пространство имён: так повторная инициализация не будет плодить обработчики, а соседние click-события останутся на месте.'),
|
||
codeBlock(String.raw`
|
||
(function ($) {
|
||
function addToCart(event) {
|
||
event.preventDefault();
|
||
|
||
var $link = $(this);
|
||
var productId = $link.data('product-id');
|
||
|
||
if (!productId) {
|
||
window.console.warn('У кнопки нет product-id');
|
||
return;
|
||
}
|
||
|
||
window.console.log('Добавляем товар ' + productId);
|
||
}
|
||
|
||
function mountProductList(root) {
|
||
var $root = $(root);
|
||
|
||
$root.off('.productList');
|
||
$root.on('click.productList', '.js-buy', addToCart);
|
||
}
|
||
|
||
window.mountProductList = mountProductList;
|
||
}(jQuery));
|
||
|
||
mountProductList('#products');
|
||
`),
|
||
paragraph('Теперь серверный ответ может заменить внутренности <code>#products</code>, а обработчик остаётся на самом контейнере. Он увидит клик, который всплывёт от новой ссылки и совпадёт с селектором <code>.js-buy</code>. Если проект меняет и сам <code>#products</code>, этот код не сделает чудо: нужно вызвать <code>mountProductList</code> для нового контейнера или выбрать более внешний, но всё ещё локальный корень.'),
|
||
heading('Проверяем разметку и событие по отдельности'),
|
||
paragraph('В legacy-проекте легко перепутать три причины: Ajax вернул не ту разметку, селектор не совпал или событие не дошло до корня. Поэтому я бы проверял их раздельно. Сначала подменяю HTML статической строкой, затем запускаю программный click, и только после этого возвращаю реальный запрос. Так сетевой сбой не маскирует ошибку жизненного цикла DOM.'),
|
||
codeBlock(String.raw`
|
||
var calls = 0;
|
||
var $root = $('<div id="products"><a class="js-buy" data-product-id="17" href="#">Купить</a></div>');
|
||
|
||
$('body').append($root);
|
||
|
||
$root.off('.demo');
|
||
$root.on('click.demo', '.js-buy', function (event) {
|
||
event.preventDefault();
|
||
calls += 1;
|
||
});
|
||
|
||
$root.html('<a class="js-buy" data-product-id="18" href="#">Купить другую</a>');
|
||
$root.find('.js-buy').trigger('click');
|
||
|
||
window.console.assert(calls === 1, 'Делегированный click должен дойти до корня');
|
||
$root.remove();
|
||
`),
|
||
paragraph('Если проверка не проходит, сначала смотрим на корень: он существует в момент вызова <code>.on()</code>, внутри него действительно лежит новая кнопка, и её класс совпадает с селектором? Затем проверяем тип события. В документации jQuery есть важные исключения: делегированные обработчики не работают для SVG, а некоторые события не всплывают. Для таких случаев нельзя механически переносить click-шаблон.'),
|
||
heading('Почему document — не первая точка'),
|
||
paragraph('У <code>document</code> есть соблазнительное свойство: он почти всегда живёт дольше виджета. Но документация jQuery советует выбирать место как можно ближе к целевым элементам. На большой странице делегирование высокочастотных событий сверху заставляет jQuery сравнивать селекторы по длинному пути всплытия. Для click на небольшом участке разница может быть незаметна, но архитектурно всё равно лучше, когда каталог слушает каталог, а не весь сайт.'),
|
||
paragraph('Есть и практическая причина. Локальный корень показывает границу ответственности: код карточек не должен случайно перехватить похожую кнопку в модальном окне или в шапке. Селектор <code>.js-buy</code> становится понятным только в контексте <code>#products</code>.'),
|
||
heading('Отдельный риск: строка HTML — это не безопасные данные'),
|
||
paragraph('У <code>.html()</code> есть ещё один неприятный край. Документация jQuery предупреждает, что методы, принимающие HTML-строку, потенциально выполняют код из вставленных тегов или атрибутов. Поэтому в пример выше строка попала только как тестовая разметка, написанная в исходнике. Нельзя передавать в <code>.html()</code> необработанный параметр URL, текст из формы или поле API, если сервер не гарантирует его безопасное формирование.'),
|
||
heading('Порядок исправления'),
|
||
orderedList([
|
||
'Найти точный вызов <code>.html()</code> или другой код, который заменяет дочерние карточки.',
|
||
'Проверить, какой ближайший контейнер не заменяется при обновлении.',
|
||
'Снять со стабильного контейнера только события конкретного виджета по пространству имён.',
|
||
'Назначить делегированный обработчик с простым селектором потомка.',
|
||
'Подменить разметку тестовой строкой и вызвать click программно, чтобы отделить DOM-проблему от сети.',
|
||
'Вернуть реальный Ajax и отдельно проверить, что HTML приходит из доверенного источника и соответствует ожидаемому контракту.',
|
||
]),
|
||
heading('Ограничения'),
|
||
bulletList([
|
||
'Делегирование не заменяет прямую привязку во всех случаях. Если нужен обработчик на самом элементе плагина или событие не всплывает, придётся выбрать другой контракт.',
|
||
'По документации jQuery делегированные обработчики не работают для SVG. Для интерактивных SVG нельзя рассчитывать на этот пример без отдельной проверки.',
|
||
'Слишком общий корень и тяжёлый селектор могут создать лишнюю работу при частых событиях. Выбираем ближайший живой контейнер и простую границу.',
|
||
'Починка click не решает вопрос повторной серверной операции. Контракт формы и запросов нужно проверять отдельно.',
|
||
]),
|
||
heading('Итог'),
|
||
paragraph('После <code>.html()</code> новая кнопка — это новый DOM-узел без старого прямого обработчика. Делегирование решает ровно эту задачу, если обработчик живёт на устойчивом и близком контейнере. Когда мы называем владельца события и проверяем замену разметки отдельно от Ajax, исчезает и необходимость в случайных повторных bind.'),
|
||
],
|
||
[jqueryHtml, jqueryOn, jqueryOff, jqueryData],
|
||
);
|
||
|
||
const fieldArticle = createRevision(
|
||
{
|
||
slug: 'editorial-2018-05-field-legacy-jquery',
|
||
title: 'jQuery. Как не отправить legacy-форму дважды',
|
||
categories: ['JavaScript', 'jQuery', 'Ajax'],
|
||
cover: '/assets/editorial/2018/jquery-ajax-form-contract.svg',
|
||
excerpt: 'Практический контракт Ajax-формы: один активный jqXHR, корректная сериализация, явные ветки успеха и ошибки, обязательное освобождение кнопки.',
|
||
readingMinutes: 10,
|
||
},
|
||
[
|
||
paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области.'),
|
||
paragraph('Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.'),
|
||
heading('Сначала определим, что именно отправляет форма'),
|
||
paragraph('Метод <code>.serialize()</code> строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть <code>name</code>, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через <code>.serialize()</code> не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.'),
|
||
paragraph('Я предпочитаю сериализовать сам <code><form></code>, а не объединять вручную все <code>input</code> на странице. Так не появляется дубль, когда в выборку по ошибке попали и форма, и её дочерние поля. Этот нюанс прямо описан в документации jQuery.'),
|
||
dataTable(
|
||
['Состояние формы', 'Что хранится на форме', 'Что видит пользователь', 'Следующий переход'],
|
||
[
|
||
['Готова', 'Ключ <code>orderRequest</code> отсутствует', 'Кнопка доступна', 'submit создаёт один jqXHR'],
|
||
['Запрос идёт', 'В <code>.data()</code> лежит маркер или jqXHR', 'Кнопка отключена', 'Повторный submit сразу выходит'],
|
||
['Успех', 'Сервер вернул ожидаемый ответ', 'Показываем подтверждённый результат', 'always освобождает интерфейс'],
|
||
['Ошибка или timeout', 'jqXHR отклонён', 'Показываем понятную ошибку', 'always освобождает интерфейс'],
|
||
],
|
||
),
|
||
figure('/assets/editorial/2018/jquery-ajax-form-contract.svg', 'Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем обязательное освобождение интерфейса', 'Ветка успеха и ветка ошибки разные, а освобождение кнопки живёт в always и не зависит от параметров ответа.'),
|
||
heading('Минимальная разметка и граница обработчика'),
|
||
paragraph('Пусть форма существует на странице постоянно. Скрытый токен и поля уже выдаёт сервер; пример не придумывает их значение. Важно только, чтобы каждое отправляемое поле имело имя, а кнопка была внутри формы.'),
|
||
codeBlock(String.raw`
|
||
<form id="order-form" action="/order/create" method="post">
|
||
<input type="hidden" name="csrf_token" value="серверное_значение">
|
||
<label>
|
||
Почта
|
||
<input name="email" type="email" required>
|
||
</label>
|
||
<label>
|
||
<input name="agree" type="checkbox" value="Y">
|
||
Согласен с условиями
|
||
</label>
|
||
<button type="submit">Оформить</button>
|
||
<p class="js-order-message" aria-live="polite"></p>
|
||
</form>
|
||
`),
|
||
paragraph('Клиентский замок я храню через <code>.data()</code> на самой форме. Это локально: на странице с двумя независимыми формами их состояния не смешаются. Для кнопки использую <code>.prop("disabled", true)</code>, а не <code>.attr</code>, потому что <code>disabled</code> — динамическое свойство DOM; jQuery отдельно рекомендует <code>.prop()</code> для <code>disabled</code> и <code>checked</code>.'),
|
||
heading('Рабочий обработчик'),
|
||
paragraph('Пример написан для jQuery 3.x и использует <code>done</code>, <code>fail</code> и <code>always</code> у объекта <code>jqXHR</code>. Метод <code>$.ajax()</code> возвращает jqXHR с Promise-интерфейсом. В <code>done</code> мы разбираем ответ, в <code>fail</code> — транспортную ошибку, а в <code>always</code> выполняем действие, которому не нужны параметры ответа: освобождаем форму.'),
|
||
codeBlock(String.raw`
|
||
(function ($) {
|
||
var requestKey = 'orderRequest';
|
||
|
||
function showMessage($form, text, isError) {
|
||
$form.find('.js-order-message')
|
||
.toggleClass('is-error', isError)
|
||
.text(text);
|
||
}
|
||
|
||
function unlock($form, $button) {
|
||
$form.removeData(requestKey);
|
||
$button.prop('disabled', false);
|
||
}
|
||
|
||
function submitOrder(event) {
|
||
event.preventDefault();
|
||
|
||
var $form = $(this);
|
||
var $button = $form.find('[type="submit"]');
|
||
|
||
if ($form.data(requestKey)) {
|
||
return;
|
||
}
|
||
|
||
$form.data(requestKey, true);
|
||
$button.prop('disabled', true);
|
||
showMessage($form, 'Отправляем…', false);
|
||
|
||
var request;
|
||
|
||
try {
|
||
request = $.ajax({
|
||
url: $form.attr('action'),
|
||
type: $form.attr('method') || 'POST',
|
||
data: $form.serialize(),
|
||
dataType: 'json',
|
||
timeout: 10000
|
||
});
|
||
} catch (error) {
|
||
unlock($form, $button);
|
||
showMessage($form, 'Не удалось начать запрос', true);
|
||
return;
|
||
}
|
||
|
||
$form.data(requestKey, request);
|
||
|
||
request
|
||
.done(function (response) {
|
||
if (!response || response.ok !== true || typeof response.orderNumber === 'undefined') {
|
||
showMessage($form, 'Сервер не подтвердил оформление', true);
|
||
return;
|
||
}
|
||
|
||
showMessage($form, 'Заказ принят: ' + response.orderNumber, false);
|
||
})
|
||
.fail(function (xhr, status) {
|
||
var text = status === 'timeout'
|
||
? 'Сервер не ответил вовремя. Проверьте статус заказа перед повтором.'
|
||
: 'Не удалось отправить форму. Попробуйте позже.';
|
||
|
||
showMessage($form, text, true);
|
||
})
|
||
.always(function () {
|
||
unlock($form, $button);
|
||
});
|
||
}
|
||
|
||
$('#order-form')
|
||
.off('submit.orderForm')
|
||
.on('submit.orderForm', submitOrder);
|
||
}(jQuery));
|
||
`),
|
||
paragraph('Маркер <code>true</code> записывается до старта Ajax. После успешного создания jqXHR он заменяется на сам объект запроса: это удобно для отладки в консоли, но в примере не используется для отмены. Если <code>$.ajax()</code> не удалось начать синхронно, блок <code>catch</code> снимает маркер и возвращает кнопку. В обычном сетевом отказе код пойдёт через <code>fail</code>, а <code>always</code> всё равно вернёт форму к начальному состоянию.'),
|
||
heading('Что именно проверяет этот код'),
|
||
paragraph('Первая защита — обработчик <code>submit</code>, а не только click на кнопке. Поэтому Enter в поле проходит тем же путём. Вторая защита — состояние на форме. Если тот же submit придёт, пока есть маркер, функция выходит без второго <code>$.ajax()</code>. Третья — переключение кнопки. Оно даёт пользователю видимый сигнал и уменьшает шанс случайного повторного действия, но не является единственным условием корректности.'),
|
||
codeBlock(String.raw`
|
||
// Временный диагностический крючок для staging:
|
||
var sent = 0;
|
||
var originalAjax = $.ajax;
|
||
|
||
$.ajax = function () {
|
||
sent += 1;
|
||
return originalAjax.apply(this, arguments);
|
||
};
|
||
|
||
$('#order-form').trigger('submit');
|
||
$('#order-form').trigger('submit');
|
||
|
||
window.console.assert(sent === 1, 'Форма не должна запускать второй Ajax до завершения первого');
|
||
`),
|
||
paragraph('Такую подмену не надо оставлять в production. Она нужна, чтобы коротко воспроизвести контракт: два submit подряд должны создать один Ajax-вызов. Для реального теста вместо неё лучше замокать endpoint или проверять запросы в браузерном тесте. Но если счётчик сразу показывает два вызова, искать ошибку на сервере ещё рано.'),
|
||
heading('Почему success не равен завершению интерфейса'),
|
||
paragraph('Иногда старый код разблокирует кнопку только в callback успеха. Тогда при timeout, 500 или ошибке сети пользователь остаётся с выключенной формой и обновляет страницу. У jqXHR есть <code>done</code>, <code>fail</code> и <code>always</code>; документация jQuery рекомендует не анализировать аргументы в <code>always</code>, потому что при resolve и reject они различаются. Это как раз подходящее место для одинакового действия: убрать локальный маркер и вернуть кнопку.'),
|
||
paragraph('Успешный HTTP-ответ тоже не обязательно означает, что операция готова. В примере договор сервера требует <code>response.ok === true</code> и номер заказа. Если API проекта отвечает иначе, нужно описать именно его контракт: какие поля обязательны, где лежит текст ошибки, можно ли повторить запрос и когда результат считается подтверждённым. Не стоит считать успехом любой JSON только потому, что запрос завершился без сетевой ошибки.'),
|
||
heading('Последовательность внедрения'),
|
||
orderedList([
|
||
'Открыть текущую форму в браузере и зафиксировать фактический URL, метод, поля и ожидаемый ответ API.',
|
||
'Проверить, что необходимые поля имеют <code>name</code>; отдельно решить, как отправляются файлы, потому что <code>.serialize()</code> их не включает.',
|
||
'Перевести обработку на <code>submit</code> и снять только прежнее событие формы через уникальное пространство имён.',
|
||
'Записать маркер до отправки, выключить кнопку через <code>.prop()</code> и создать один jqXHR.',
|
||
'Разделить подтверждённый бизнес-ответ, ошибку транспорта и общее освобождение интерфейса.',
|
||
'Проверить два submit подряд, timeout и ответ API с ошибкой; после каждого сценария форма должна либо показать результат, либо снова стать доступной.',
|
||
]),
|
||
heading('Ограничения'),
|
||
bulletList([
|
||
'Клиентский маркер существует только в текущем DOM. Обновление страницы, второй браузер, ручный HTTP-запрос или повтор после timeout могут создать новый запрос. Критичная операция должна быть защищена на сервере по правилам конкретного домена.',
|
||
'В примере нет загрузки файлов. Документация jQuery указывает, что file input не сериализуется через <code>.serialize()</code>; для него нужен отдельный согласованный транспорт.',
|
||
'Не показываем номер заказа из любого произвольного ответа. Формат <code>ok</code> и <code>orderNumber</code> — пример контракта, который сервер должен подтвердить.',
|
||
'Timeout — это отсутствие ответа за выбранный интервал, а не доказательство, что сервер ничего не сделал. Поэтому текст ошибки не обещает безопасный повтор, пока проект не определил проверку статуса операции.',
|
||
]),
|
||
heading('Итог'),
|
||
paragraph('У legacy Ajax-формы должно быть немного состояний и ни одного скрытого перехода: формы нет в запросе, форма ждёт один jqXHR, затем показывает подтверждённый результат или ошибку и в любом случае освобождает интерфейс. Такой код не решает серверную идемпотентность, но перестаёт создавать собственные дубли и даёт читабельную точку для следующей диагностики.'),
|
||
],
|
||
[jqueryAjax, jquerySerialize, jqueryProp, jqueryData, jqueryRemoveData, jqueryAlways],
|
||
);
|
||
|
||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
|
||
.map(({ bodyLength, ...revision }) => revision);
|
||
|
||
if (process.argv[1]?.endsWith('/upgrade-2018-05.mjs')) {
|
||
if (process.argv.includes('--print-revisions')) {
|
||
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
|
||
} else {
|
||
process.stderr.write('Usage: node web/scripts/upgrade-2018-05.mjs --print-revisions\n');
|
||
process.exitCode = 1;
|
||
}
|
||
}
|