Отмена повторяющихся запросов на Fetch в расширенных формах JavaScript
Если вы когда-либо использовали JavaScript fetch
API для улучшения отправки формы, есть большая вероятность, что вы случайно ввели ошибку с дублированием запроса/состоянием гонки. Сегодня мы расскажем вам об этой проблеме и своих рекомендациях, как ее избежать.
Давайте рассмотрим очень простую HTML-форму с одним вводом и кнопкой отправки.
<form method="post">
<label for="name">Name</label>
<input id="name" name="name" />
<button>Submit</button>
</form>
Когда мы нажмем кнопку отправки, браузер обновит всю страницу целиком.
Обратите внимание, как браузер перезагружается после нажатия кнопки отправки.
Обновление страницы не всегда является тем, что мы хотим предложить нашим пользователям, поэтому обычной альтернативой является использование JavaScript для добавления прослушивателя событий к событию “отправить” формы, предотвращения поведения по умолчанию и отправки данных формы с помощью fetch
API.
Упрощенный подход может выглядеть как приведенный ниже пример. После монтирования страницы (или компонента) мы захватываем узел form DOM, добавляем прослушиватель событий, который создает запрос на fetch
, используя действие action, method и data, и в конце обработчика мы вызываем метод preventDefault()
события.
const form = document.querySelector('form');
form.addEventListener('submit', handleSubmit);
function handleSubmit(event) {
const form = event.currentTarget;
fetch(form.action, {
method: form.method,
body: new FormData(form)
});
event.preventDefault();
}
Теперь, прежде чем какие-нибудь горячие головы JavaScript начнут писать нам о GET против POST и теле запроса, Content-Type и всем остальном, позвольте нам просто сказать, что мы знаем. Мы намеренно упрощаем запрос на fetch
, потому что это не главное.
Ключевой проблемой здесь является event.preventDefault()
. Этот метод не позволяет браузеру выполнять поведение по умолчанию - загружать новую страницу и отправлять форму.
Теперь, если мы посмотрим на экран и нажмем отправить, мы увидим, что страница не перезагружается, но мы видим HTTP-запрос на нашей вкладке сеть.
Обратите внимание, что браузер не выполняет полную перезагрузку страницы.
К сожалению, используя JavaScript для предотвращения поведения по умолчанию, мы фактически внедрили ошибку, которой нет в поведении браузера по умолчанию.
Когда мы используем обычный HTML и вы нажимаете кнопку отправки несколько раз очень быстро, вы заметите, что все сетевые запросы, кроме самого последнего, становятся красными. Это указывает на то, что они были отменены и удовлетворен только самый последний запрос.
Если мы сравним это с примером JavaScript, мы увидим, что все запросы отправлены и все они завершены без какой-либо отмены.
Это может быть проблемой, поскольку, хотя каждый запрос может занимать разное количество времени, они могут быть разрешены в другом порядке, чем они были инициированы. Это означает, что если мы добавим функциональность для разрешения этих запросов, у нас может возникнуть какое-то неожиданное поведение.
Например, мы могли бы создать переменную для увеличения для каждого запроса («totalRequestCount
»). Каждый раз, когда мы запускаем функцию handleSubmit
, мы можем увеличивать общее количество, а также захватывать текущее число для отслеживания текущего запроса («thisRequestNumber
»). Когда запрос на fetch
разрешается, мы можем записать соответствующий номер в консоль.
const form = document.querySelector('form');
form.addEventListener('submit', handleSubmit);
let totalRequestCount = 0
function handleSubmit(event) {
totalRequestCount += 1
const thisRequestNumber = totalRequestCount
const form = event.currentTarget;
fetch(form.action, {
method: form.method,
body: new FormData(form)
}).then(() => {
console.log(thisRequestNumber)
})
event.preventDefault();
}
Теперь, если мы нажмем на эту кнопку отправки несколько раз, мы можем увидеть, что разные цифры выводятся на консоль не по порядку: 2, 3, 1, 4, 5. Это зависит от скорости сети, но мы думаем, мы все можем согласиться с тем, что это не идеально.
Рассмотрим сценарий, в котором пользователь запускает несколько запросов на fetch
в непосредственной последовательности, и по завершении ваше приложение обновляет страницу с учетом их изменений. Пользователь может в конечном итоге увидеть неточную информацию из-за того, что запросы разрешаются не по порядку.
Это не проблема в мире без JavaScript, потому что браузер отменяет любой предыдущий запрос и загружает страницу после завершения самого последнего запроса, загружая самую последнюю версию. Но обновление страницы не так привлекательно.
Хорошая новость для любителей JavaScript заключается в том, что у нас может быть как привлекательный пользовательский интерфейс, так и согласованный UI!
Нам просто нужно еще немного поработать.
Если вы посмотрите документацию по API fetch
, вы увидите, что можно прервать выборку с помощью AbortController
и свойства signal
параметров выборки. Это выглядит примерно так:
const controller = new AbortController();
fetch(url, { signal: controller.signal });
Предоставляя сигнал AbortControlleris
для запроса на fetch
, мы можем отменить запрос в любое время, когда запускается метод abort
AbortController
.
Вы можете увидеть более наглядный пример в консоли JavaScript. Попробуйте создать AbortController
, инициировав запрос на fetch
, а затем сразу же выполнив метод abort
.
const controller = new AbortController();
fetch('', { signal: controller.signal });
controller.abort()
Вы должны немедленно увидеть исключение, выведенное на консоль. В браузерах Chromium должно быть написано: “Uncaught (in promise) DOMException: Пользователь прервал запрос.” И если вы откроете вкладку Сеть, вы должны увидеть неудачный запрос с текстом статуса “(canceled)”.
Имея это в виду, мы можем добавить AbortController
к обработчику отправки нашей формы. Логика будет следующей:
- Во-первых, проверьте
AbortController
для любого предыдущего запроса. Если он существует, прервите его. - Затем создайте AbortController для текущего запроса, который можно прервать при последующих запросах.
- Наконец, когда запрос разрешается, удалите соответствующий ему
AbortController
.
Есть несколько способов сделать это, но мы будем использовать WeakMap
для хранения взаимосвязей между каждым представленным DOM-узлом <form>
и соответствующим ему AbortController
. Когда форма отправлена, мы можем проверить и соответствующим образом обновить WeakMap
.
const pendingForms = new WeakMap();
function handleSubmit(event) {
const form = event.currentTarget;
const previousController = pendingForms.get(form);
if (previousController) {
previousController.abort();
}
const controller = new AbortController();
pendingForms.set(form, controller);
fetch(form.action, {
method: form.method,
body: new FormData(form),
signal: controller.signal,
}).then(() => {
pendingForms.delete(form);
});
event.preventDefault();
}
const forms = document.querySelectorAll('form');
for (const form of forms) {
form.addEventListener('submit', handleSubmit);
}
Ключевым моментом является возможность связать контроллер прерывания с соответствующей формой. Использование узла DOM формы в качестве ключа WeakMap
— удобный способ сделать это. Имея это на месте, мы можем добавить сигнал AbortController
к запросу на fetch
, отменить все предыдущие контроллеры, добавить новые и удалить их по завершении.
Надеемся, это все имеет смысл.
Теперь, если мы нажмем кнопку отправки этой формы несколько раз, мы сможем увидеть, что все запросы API, кроме самого последнего, будут отменены.
Это означает, что любая функция, отвечающая на этот HTTP-ответ, будет вести себя более ожидаемо. Теперь, если мы используем ту же логику подсчета и ведения журнала, что и выше, мы можем нажать кнопку отправки семь раз и увидим шесть исключений (из-за AbortController
) и один журнал «7» в консоли. Если мы снова отправим запрос и дадим ему достаточно времени для разрешения, мы увидим «8» в консоли. И если мы снова нажмем кнопку отправки несколько раз, мы продолжим видеть исключения и окончательный подсчет запросов в правильном порядке.
Если вы хотите добавить еще немного логики, чтобы не видеть DOMExceptions в консоли, когда запрос прерван, вы можете добавить блок .catch()
после вашего запроса на fetch
и проверить, соответствует ли имя ошибки «AbortError
»:
fetch(url, {
signal: controller.signal,
}).catch((error) => {
// If the request was aborted, do nothing
if (error.name === 'AbortError') return;
// Otherwise, handle the error here or throw it back to the console
throw error
});
Завершение
Весь этот пост был посвящен формам, улучшенным с помощью JavaScript, но, вероятно, хорошей идеей будет включать AbortController
каждый раз, когда вы создаете запрос на fetch
. Действительно, очень жаль, что это еще не встроено в API. Но, надеемся, это покажет вам хороший метод для его включения.
Стоит также отметить, что этот подход не мешает пользователю спамить кнопку отправки несколько раз. Кнопка по-прежнему нажимается, и запрос по-прежнему срабатывает, это просто обеспечивает более последовательный способ обработки ответов.
К сожалению, если пользователь спамит кнопку отправки, эти запросы все равно будут отправляться на ваш сервер и могут потреблять кучу ненужных ресурсов.
Некоторые наивные решения могут заключаться в отключении кнопки отправки, использовании debounce или создании новых запросов только после разрешения предыдущих. Нам не нравятся эти варианты, потому что они полагаются на замедление работы пользователя и работают только на стороне клиента. Они не устраняют злоупотребления с помощью скриптовых запросов.
Чтобы устранить злоупотребление из-за слишком большого количества запросов к вашему серверу, вы, вероятно, захотите установить некоторое ограничение скорости. Это выходит за рамки этого поста, но стоило упомянуть. Стоит также упомянуть, что ограничение скорости не решает изначальную проблему дублирования запросов, состояния гонки и непоследовательных обновлений пользовательского интерфейса. В идеале мы должны использовать оба, чтобы покрыть оба конца.
В любом случае, это все, что у меня есть на сегодня. Если вы хотите посмотреть видео, посвященное той же теме, посмотрите это.