Гонки сетевых ответов
Отмена экономит ненужную работу, когда источник её поддерживает. Однако правильность интерфейса не должна зависеть только от согласия источника остановиться. Старая операция может уже завершаться, внешний адаптер может не принимать сигнал, а дополнительная обработка результата может продолжиться независимо. Поэтому отдельно определим, какой ответ имеет право менять страницу.
В снимке advanced/lesson-23 используем номера поколений запроса. Он основан на прежнем договоре topic/page, но специально не отменяет источник. Так можно увидеть собственный результат защиты от гонки, а не принять эффект отмены за доказательство новой проверки. Программа пока не запускалась; далее указаны ожидаемые наблюдения.
Как возникает обратный порядок
Два применения формы обозначим как A и B. Сначала читатель выбрал фронтенд, затем публикацию. Намерение B новее, но время завершения не обязано следовать порядку запуска. Если ответ B готов раньше A, простое присваивание после каждого await сначала покажет публикацию, затем вернёт устаревший фронтенд.
Это называют гонкой ответов. Для неё не требуется одновременное изменение одной переменной несколькими потоками JavaScript. Между асинхронными завершениями есть разные допустимые порядки, и программа должна задать правило применения. Однопоточная обработка callbacks не определяет, какой сетевой результат является актуальным по смыслу продукта.
В нашем локальном источнике задержка завершения для фронтенда равна девятистам миллисекундам, для других тем — ста. Эти величины искусственные и применяются после чтения JSON. Они не описывают реальные скорости технологий и не гарантируют фиксированную длительность всей загрузки. Их задача — создать понятный опыт с разным временем результата.
Поколение попытки
Переменная generation увеличивается при каждой новой попытке. Текущий вызов сохраняет её значение в локальном mine. Перед изменением состояния он сравнивает своё поколение с последним. Совпадение означает, что более новое действие ещё не заменило его намерение.
Полный блок обновления в снимке заменяет вариант урока 22:
let generation = 0;
async function refresh(next, write = false) {
const mine = ++generation;
status.textContent = 'Загружаем страницу…';
try {
const result = await loadPage(next, {
delayMs: next.topic === 'frontend' ? 900 : 100,
});
if (mine !== generation) return;
selection = next;
topicField.value = next.topic;
renderCourses(list, result.items);
status.textContent = `Показано: ${result.items.length}. Всего: ${result.total}.`;
if (write) writeSelection(next);
} catch (error) {
if (mine !== generation) return;
status.textContent = 'Страница не загружена.';
console.error(error);
}
}
В начале A получит, например, поколение один. После запуска B общая переменная станет равна двум. Если A завершится позже, его локальное значение останется единицей: новый вызов не переназначает локальную переменную старого. Поэтому проверка отвергнет применение A даже при корректном полученном массиве.
Номер здесь не является версией серверных данных и не записывается в URL. Он относится только к актуальности попыток одного интерфейса. После обновления документа начнётся новое состояние счётчика, что не нарушает договор: прежние callbacks уже не управляют новой страницей.
Проверяются и успех, и отказ
Недостаточно защитить только renderCourses. Старый отказ также способен изменить сообщение после успешного нового результата. Поэтому catch содержит ту же проверку поколения. Ошибка устаревшего запроса не должна очищать актуальные карточки или объявлять новое состояние неуспешным.
Если понадобится блок finally, его изменения тоже проверяются. Например, старое завершение не должно включить кнопку, пока новая операция ещё загружается. Обычно именно такие малозаметные побочные действия остаются после правильной защиты массива. Политика поколения должна охватывать все изменения состояния конкретной попытки, а не одну последнюю строку.
При этом отказ последней операции остаётся настоящим отказом. Мы не показываем результат старого A как запасной только потому, что B не удался. Если продукт хочет сохранение предыдущих карточек при ошибке нового запроса, это отдельное явно обозначенное состояние. Оно не должно превращаться в молчаливое утверждение, что старые данные соответствуют новому выбору.
Связь асинхронных этапов с результатами Promise объясняется в MDN о promises. Номер поколения — собственный договор нашего приложения поверх этого механизма, а не специальная встроенная возможность Promise.
Отмена и актуальность вместе
Сигнал из прошлого урока и поколение решают связанные, но разные задачи. Сигнал просит источник завершить ненужную работу. Сравнение поколения не позволяет старому результату изменить интерфейс даже тогда, когда работа продолжилась. В следующем постраничном снимке применим оба механизма.
Не рассчитывайте на одно название ошибки как доказательство правильного порядка. Корректный старый ответ может не вызвать исключение вообще. И наоборот, ранний отменённый ответ уже мог прочитать часть данных. Правильность применения определяется намерением интерфейса и локальным поколением, а не только характером сетевого завершения.
Имеет значение также момент увеличения счётчика. Он должен происходить при запуске новой намеренной операции до ожидания результата. Если увеличивать его после await, более старый вызов сможет стать «последним» просто потому, что пришёл позднее. Тогда счётчик закрепит неправильный порядок вместо защиты.
Снимок параметров
Объект next представляет параметры конкретного вызова. Обработчик формы создаёт новый объект, а не передаёт изменяемую общую ссылку, которую следующий выбор перепишет. Если оба вызова читали бы один объект и его поля менялись во время ожидания, первый запрос мог бы применять подпись второго выбора вместе со своим ответом.
Поэтому у попытки есть две локальные вещи: параметры, с которыми она запущена, и номер актуальности. Данные ответа должны соответствовать этим параметрам. Модуль источника дополнительно проверяет страницу и тему записей. Эти границы дополняют друг друга: корректная схема ответа ещё не означает, что он имеет право менять текущий интерфейс.
Для ручного опыта быстро примените фронтенд, затем публикацию. Ожидается результат публикации без позднего возврата. Временное удаление сравнения в копии снимка должно показать возможную проблему, но не является обязательным действием на рабочем сайте. Держите исходную защищённую версию отдельно, чтобы восстановить договор после наблюдения.
Актуальная попытка не означает свежие данные
Пусть последний запрос получил правильную по схеме страницу, но сервер сформировал её из устаревшей копии каталога. Номер поколения всё равно разрешит применить ответ: он проверяет порядок наших действий, а не содержимое серверного хранилища. Для свежести данных нужны отдельные условия источника, версии или политика кеша. В снимке используется cache: 'no-store', чтобы ручное изменение локального JSON не маскировалось прежним HTTP-ответом; это также не превращает поколение в версию каталога.
Рассмотрим три попытки вместо двух. A получает номер один, B — два, C — три. После запуска C ни успешный A, ни отказ B не имеют права менять сообщение. Когда C заканчивается, сравнение разрешает только его изменения. Алгоритму не нужно знать, какой из старых ответов пришёл первым: каждый самостоятельно сравнивает свою сохранённую отметку с общей текущей.
Такая проверка должна стоять до первой операции применения после ожидания. Если сначала записать selection, а затем отвергнуть старый render, подпись и кнопки уже окажутся связанными с неверным выбором. Поэтому мысленно перечислите все изменяемые вещи: параметры, список, статус, URL и занятость. Правильный фильтр только вокруг карточек оставляет гонку остальных частей.
В этом уроке каждое применение формы создаёт отдельную операцию и отдельный номер. Можно было бы объединить одинаковые запросы, но тогда появилось бы правило совместного ожидания и нескольких потребителей. Не добавляем его к счётчику незаметно. Простая граница «последняя начатая попытка» понятна и для повторения той же темы после ошибки.
Счётчик не является отменой и не предотвращает расход ресурсов старой работы. После раннего выхода значение уже получено, а предыдущая сеть могла завершиться полностью. Поэтому следующая версия соединяет экономию ненужного ожидания через сигнал с защитой применения через поколение. Наблюдаемый успех — правильный конечный выбор и сообщение, а не обещание отсутствия любой прошлой активности.
Повторите опыт с ошибкой первого источника и успешным вторым. Ожидается, что старый catch не заменит сообщение второго результата. Эти сценарии пока описаны как ручные проверки, а не выполненный тест. Следующий урок использует ту же границу при переходах между страницами и управлении кнопками навигации.