Загрузка и отмена устаревшего запроса
Оглавление серии «Vue 3: кабинет сотрудников «Команда»»
Подключим GET /api/members к общему состоянию. На экране появятся ожидание, отказ и возможность повторить чтение. Результат урока — управление жизненным циклом запроса: новый запрос становится актуальным, предыдущий отменяется, а его поздний результат не меняет команду. Серверный договор прошлой страницы является обязательной предпосылкой; архив не содержит его реализации.
Два обстоятельства особенно важны для кабинета. Во-первых, пустая команда означает успешный ответ с пустым массивом, а не отсутствие ответа вообще. Во-вторых, повторное обновление может завершиться иначе, чем первоначальная загрузка. Мы сохраняем прежние записи до успешного нового ответа и показываем состояние отдельно. Пользователь не теряет прочитанный список лишь из-за временного сетевого отказа.
Контроллер актуальной загрузки
Полностью заменим src/state/team.js. Начальная коллекция теперь пуста; локальное добавление и переключение удалены из используемого договора. Пока сервер отвечает только за чтение, интерфейс не должен продолжать выдавать локальное изменение за подтверждённую удалённую запись.
import { readonly, ref } from 'vue'
import { listMembers } from '../api/team.js'
const members = ref([])
const loading = ref(false)
const loaded = ref(false)
const error = ref('')
let currentController = null
let generation = 0
async function load() {
currentController?.abort()
const controller = new AbortController()
currentController = controller
const ticket = ++generation
loading.value = true; error.value = ''
try {
const result = await listMembers(controller.signal)
if (ticket === generation) { members.value = result; loaded.value = true; return true }
return false
} catch (failure) {
if (ticket === generation && failure.name !== 'AbortError')
error.value = 'Не удалось обновить команду. Повторите чтение.'
return false
} finally {
if (ticket === generation) { loading.value = false; currentController = null }
}
}
function cancelLoad() {
generation += 1; currentController?.abort(); currentController = null; loading.value = false
}
export function useSharedTeam() {
return { members: readonly(members), loading: readonly(loading), loaded: readonly(loaded),
error: readonly(error), load, cancelLoad }
}
AbortController передаёт сигнал fetch. Описание AbortController объясняет отмену асинхронной операции, но отмена сама по себе не является полным договором актуальности. Счётчик generation дополнительно проверяет, принадлежит ли результат текущему чтению. Старый finally также не должен выключать индикатор нового запроса, поэтому такая же проверка стоит в завершении.
Где запрос начинает жить
Для этого состояния запросом владеет корневой App, который существует при смене экранов. Ниже его полный файл. При уничтожении приложения незавершённое чтение отменяется. Жизненный цикл Vue полезен для понимания, почему регистрацию хуков выполняют синхронно в setup, а не внутри произвольного позднего callback.
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useSharedTeam } from './state/team.js'
const { load, cancelLoad } = useSharedTeam()
onMounted(load)
onUnmounted(cancelLoad)
</script>
<template>
<main>
<h1>Команда</h1>
<nav aria-label="Кабинет"><RouterLink :to="{ name: 'members' }">Сотрудники</RouterLink></nav>
<RouterView />
</main>
</template>
Полные замены обоих экранов показывают загрузку и ошибку одинакового источника. На карточке состояние отсутствия допускается только после успешного чтения.
<script setup>
import { computed, ref } from 'vue'
import { useSharedTeam } from '../state/team.js'
const { members, loading, loaded, error, load } = useSharedTeam()
const query = ref('')
const visibleMembers = computed(() => members.value.filter(member =>
`${member.name} ${member.role}`.toLocaleLowerCase('ru')
.includes(query.value.trim().toLocaleLowerCase('ru'))))
</script>
<template>
<section :aria-busy="loading">
<h2>Сотрудники</h2>
<button type="button" @click="load">Обновить команду</button>
<p v-if="loading" role="status">Получаем участников.</p>
<p v-if="error" role="alert">{{ error }}</p>
<label>Поиск <input v-model="query" type="search"></label>
<p v-if="loaded">Показано {{ visibleMembers.length }} из {{ members.length }}</p>
<p v-if="loaded && !visibleMembers.length">Подходящих участников нет.</p>
<ul><li v-for="member in visibleMembers" :key="member.id">
<RouterLink :to="{ name: 'member', params: { id: member.id } }">{{ member.name }}</RouterLink>
— {{ member.role }} — {{ member.available ? 'Доступен' : 'Занят' }}
</li></ul>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSharedTeam } from '../state/team.js'
const route = useRoute()
const { members, loaded, loading, error, load } = useSharedTeam()
const member = computed(() => members.value.find(item => item.id === route.params.id))
</script>
<template>
<section :aria-busy="loading">
<p v-if="loading" role="status">Получаем участников.</p>
<p v-if="error" role="alert">{{ error }}</p>
<button type="button" @click="load">Обновить команду</button>
<article v-if="member"><h2>{{ member.name }}</h2><p>{{ member.role }}</p>
<p>{{ member.available ? 'Доступен' : 'Занят' }}</p>
</article>
<p v-else-if="loaded && !loading">Участник не найден.</p>
</section>
</template>
При ожидаемом успешном ответе loaded становится true и появляются серверные карточки. Повторный клик может оставить список видимым во время нового чтения. Если оно завершается отказом, прежний список остаётся, а error сообщает о неудачном обновлении. Это не гарантирует свежесть записей, поэтому индикатор и сообщение должны читаться отдельно от самих карточек. Нельзя трактовать видимый старый список как доказательство удачного запроса.
Отмена не равна предметной ошибке
AbortError обычно означает, что этот запрос больше не нужен. Пользователь не должен видеть «команда недоступна» из-за собственной повторной команды обновления. Поэтому отмена не записывается в error. Другие отказы сохраняют предметное сообщение, даже если низкоуровневое исключение имеет мало полезного текста. Для реального входа статус 401 можно сопоставить с предложением открыть страницу авторизации; пока общий текст не обещает точную причину.
Счётчик важен и для транспорта, который не успел остановиться. Предположим, первое чтение получило старую версию Анны, затем началось второе и вернуло новую. Позднее завершение первого не должно восстановить старую команду. Без проверки ticket итог зависел бы от порядка завершения сети, а не от последнего намерения пользователя. Полезный неправильный случай не требует выдуманного замера времени: достаточно рассмотреть две допустимые последовательности завершения.
Метод cancelLoad увеличивает generation прежде, чем отменяет сигнал. Поэтому даже готовый к завершению прежний callback теряет право менять state. При повторном mount новый load получает новое поколение. Здесь существует один владелец загрузки — App. Если независимые компоненты начнут отменять общий запрос при своём размонтировании, они могут мешать друг другу. Общий ресурс требует ясного владельца, а не произвольной очистки во всех потребителях.
Данные и черновик в следующем состоянии
Поиск этого снимка снова локален без сохранения в storage. Это явное упрощение представления при переходе к серверу; серверная коллекция и правило актуальности не зависят от сохранённого поискового слова. При желании прежнюю настройку можно подключить отдельным composable, но не следует смешивать её отказ со статусом API. Существование старого файла useTeam не означает, что сетевой экран теперь вызывает его автоматически.
Самостоятельно опишите состояние пяти полей members, loading, loaded, error и generation после первой ошибки, первой удачи и повторной ошибки. Особенно важно, что loaded после удачного чтения не сбрасывается при следующем отказе. Следующий урок добавит изменение доступности, однако подтверждение будет происходить отдельным PATCH, а поле формы останется черновиком до успешного ответа сервера.
Состояние ответа и причина пустого экрана
До первого успешного запроса loaded равно false. Поэтому отсутствие member на прямом адресе не даёт права говорить, что сотрудника нет. Он может ещё загружаться или вообще не был прочитан из-за отказа. После успешной полной коллекции отсутствие действительно относится к текущему набору. Это правило связывает смысл сообщения с доказательством чтения, а не только с ложностью результата find.
При ошибке после удачной загрузки экран может одновременно иметь member и error. Такое сочетание корректно: карточка является прежним результатом, ошибка относится к обновлению. Если сделать все состояния взаимоисключающими через одну цепочку v-if, можно случайно скрыть полезные данные. Для первого чтения и повторного обновления требования различаются, поэтому шаблон показывает независимые части явно.
aria-busy сообщает об обновлении области, а role status позволяет озвучить ожидание. При частых повторных запросах не следует непрерывно выводить длинные сообщения, мешающие читать список. Здесь текст короткий и изменяется по состоянию loading. Реальный кабинет может потребовать более точного объявления завершения, но это следует проверять с пользователями вспомогательных технологий, а не выводить из наличия одного атрибута.
Если сервер возвращает огромную полную команду, отмена fetch не решает стоимость уже начавшегося разбора и отрисовки. Это другой масштаб задачи, требующий ограничения ответа и представления. Мы обсуждаем актуальность небольшого списка и не заявляем, что AbortController является общей оптимизацией любого объёма данных. Правило поколения предотвращает неправильный результат независимо от ожидаемой скорости сети.
Для самостоятельного воспроизведения результата «Связать результат только с актуальным запросом» выберите lesson-13 в архиве исходников. Статус выполнения этого состояния: not_run.