Данни от API в компонент
Накратко: Данните от API във Vue компонент се зареждат от watch със състояние „зарежда“, „грешка“ или „готово“, а AbortController в почистването спира остарялата заявка.
Проблем
Под „Напитки“ има ябълки
Каталогът взима продуктите на категорията от API-то. Тук сървърът отговаря на „Плодове и зеленчуци“ за 1,5 секунди, а на другите категории за 0,3.
Конзола
Натисни „Плодове и зеленчуци“ и веднага „Напитки“. Появяват се напитките. Секунда по-късно на тяхно място идват ябълки, банани и домати, а заглавието и натиснатият бутон пак казват „Напитки“. Купувачът вижда грешни продукти и не знае защо.
Идеята
Заявката е ефект
Заявката излиза от компонента към сървъра. Затова е страничен ефект и мястото ѝ е в ефекта, с категорията като зависимост: всяка нова категория пуска нова заявка.
Състоянията са тези от урока за fetch в страницата: зарежда, грешка, готово. Тук те са едно състояние, status, а компонентът рисува клона, който му отговаря, като в урока за условното показване.
watch(() => props.categoryId, async (categoryId) => { status.value = 'loading'; try { const response = await fetch(`/api/products?category=${categoryId}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); products.value = await response.json(); status.value = 'ready'; } catch (e) { status.value = 'error'; }}, { immediate: true });Функцията на watch може да е async. { immediate: true } зарежда първата категория още при създаването на компонента, а после всяка смяна на categoryId пуска нова заявка.
status става 'loading' в началото на всяко зареждане, а не само първия път. Иначе при смяна старият списък стои под новото заглавие, докато дойде отговорът.
Пробвай
Зареди категорията
Catalog има състоянията products и status, но нищо не ги пълни. Зареди продуктите на categoryId от /api/products?category=… и покажи „Зареждане…“ при всяко зареждане. Проверката гледа млечните (5 продукта), после натиска „Напитки“ и чака „Зареждане…“ и 4 продукта.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Млечни' },
{ id: 5, name: 'Плодове и зеленчуци' },
{ id: 6, name: 'Напитки' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Категории">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
watch(() => props.categoryId, async (categoryId) => {
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
status.value = 'error';
}
}, { immediate: true });
</script>
<template>
<p v-if="status === 'loading'" role="status">Зареждане…</p>
<p v-else-if="status === 'error'" role="alert">Продуктите не се заредиха.</p>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
watch(() => props.categoryId, async (categoryId) => { … }, { immediate: true }) слага 'loading', чака отговора и слага продуктите и 'ready'. Проверката хваща watch без immediate: тогава първата категория стои на „Зареждане…“ завинаги.
Проверката хваща и 'loading', сложено само в началото на компонента: при смяна на категорията „Зареждане…“ не се появява.
Идеята
Надбягването
Всяко натискане сменя категорията и пуска ефекта наново. Всяко пускане праща своя заявка и чака своя отговор. Нито едно не знае за другите.
Отговорите не идват в реда на заявките. Сървърът може да търси плодовете по-дълго, мрежата може да забави едната. Затова, когато купувачът превключва бързо, две заявки са на път едновременно. Печели тази, която отговори последна, а не тази, която е тръгнала последна.
Това се казва надбягване (race condition). То не личи, докато сървърът е бърз и еднакъв. Личи при бавна мрежа, на телефон, при натоварен сървър.
Виж го
Виж две заявки на път
Лентите растат, докато заявката пътува. Първо без почистване, после с него.
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => { const controller = new AbortController(); onCleanup(() => controller.abort()); status.value = 'loading'; try { const url = `/api/products?category=${categoryId}`; const response = await fetch(url, { signal: controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); products.value = await response.json(); status.value = 'ready'; } catch (e) { if (e.name === 'AbortError') return; status.value = 'error'; }}, { immediate: true });Стъпка по стъпка, с думи
- Купувачът натиска „Плодове и зеленчуци“. Категорията се сменя, ефектът тръгва и праща заявка. Тази е бавна.
- Преди тя да отговори, купувачът натиска „Напитки“. Ефектът тръгва пак и праща втора заявка. Сега две заявки пътуват едновременно.
- Бързата заявка отговаря първа: списъкът показва напитките.
- Да кажем, че почистването липсва. Бавната заявка отговаря последна, нейното пускане също слага продуктите и под „Напитки“ има плодове.
- Сега с почистването. Всяко пускане на ефекта прави свой AbortController и подава неговия signal на fetch.
- Новата категория вика почистването на старото пускане: abort() спира бавната заявка. Нейният fetch хвърля AbortError, а catch го пропуска: това не е грешка.
- Отговаря само заявката за напитките. Списъкът и заглавието съвпадат.
- Правилото: заявка в ефект има отказ в почистването. Последното пускане печели, защото всички преди него са спрени.
Без почистване екранът показва последния отговор. С почистване старата заявка е спряна, преди да е отговорила, и последният отговор е винаги на последната категория.
Идеята
Отказ в почистването
Ефектът вече има място за „спри старото“: почистването. Браузърът има инструмент за спиране на заявка: AbortController.
- Всяко пускане на ефекта прави свой
controller = new AbortController(). - Подава неговия
controller.signalнаfetch:fetch(url, { signal: controller.signal }). - Почистването вика
controller.abort(). Заявката спира, аawait fetch(…)хвърля грешка с имеAbortError. catchпропуска тази грешка:if (e.name === 'AbortError') return;. Заявката не е паднала, кодът сам я е спрял.
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => { const controller = new AbortController(); onCleanup(() => controller.abort()); status.value = 'loading'; try { const response = await fetch(url, { signal: controller.signal }); … } catch (e) { if (e.name === 'AbortError') return; status.value = 'error'; }}, { immediate: true });onCleanup е преди първия await. Ако е след него, почистването се записва чак когато отговорът е дошъл, и вече няма какво да спре.
Почистването върви и когато компонентът си отиде. Купувачът е излязъл от каталога: заявката спира и никой не слага продукти в компонент, който го няма.
Пробвай
Спри старата заявка
Направи така, че при бързо превключване старата заявка да спре. Проверката натиска „Плодове и зеленчуци“ и веднага „Напитки“, чака „Зареждане…“ и напитките, изчаква бавния отговор и гледа, че ябълки няма, а съобщение за грешка не се е появило.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Млечни' },
{ id: 5, name: 'Плодове и зеленчуци' },
{ id: 6, name: 'Напитки' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Категории">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });
</script>
<template>
<p v-if="status === 'loading'" role="status">Зареждане…</p>
<p v-else-if="status === 'error'" role="alert">Продуктите не се заредиха.</p>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
Ефектът прави AbortController, подава signal на fetch и записва onCleanup(() => controller.abort()). catch пропуска AbortError.
Проверката хваща контролер, чийто signal не е подаден на fetch: abort() не спира нищо и ябълките пак идват. Хваща и catch, който не пропуска AbortError: веднага след „Напитки“ отказът слага 'error', и вместо „Зареждане…“ за миг се вижда „Продуктите не се заредиха.“
Пробвай
Намери грешката
Купувачът превключва бързо и за миг вижда „Продуктите не се заредиха.“, после идват продуктите. Сървърът не е върнал грешка. Кой ред е виновен?
Избери реда с грешката.
Ред 11: всяка грешка става 'error', и отказът от почистването също. Преди него трябва if (e.name === 'AbortError') return;: спряната заявка не е паднала.
Пробвай
Попълни
Какво спира старата заявка?
watch(() => props.categoryId, (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.());
fetch(url, { signal: controller.signal }).then(show);
}, { immediate: true });abort: controller.abort() спира заявката, чийто signal е подаден на fetch, и тя хвърля AbortError.
Идеята
Грешка и „Опитай пак“
Сървърът понякога отговаря с 500. Проверката на response.ok хвърля, catch слага 'error', а компонентът показва съобщението с role="alert". Купувачът трябва да може да опита пак, без да презарежда страницата.
Опитът наново е същото зареждане. Ефектът вече го прави, когато се смени зависимост. Затова „Опитай пак“ сменя зависимост: число attempt, което само брои опитите.
const attempt = ref(0);
watch([() => props.categoryId, attempt], async ([categoryId], old, onCleanup) => { // … същото зареждане}, { immediate: true });<button type="button" @click="attempt++">Опитай пак</button>Източникът е масив: watch тръгва, когато се смени кое да е от двете. Функцията получава масив със стойностите.
Новото пускане слага 'loading', така че съобщението и бутонът изчезват, докато заявката пътува.
По-точно, за любопитните
Библиотеки за данни. В истински проект зареждането рядко се пише на ръка във всеки компонент. Има библиотеки, които правят това и още: пазят отговорите в кеш, не пращат две еднакви заявки, опитват пак сами, обновяват данните, когато купувачът се върне на раздела. За Vue това са useFetch от VueUse, Pinia Colada и TanStack Query за Vue. Те не са в пясъчника и курсът няма уроци за тях. Под капака правят същото като тук: заявка в ефект, състояния, отказ.
Флаг вместо отказ. Вместо AbortController почистването може да вдигне флаг: let ignore = false; в ефекта, ignore = true в почистването и if (!ignore) преди да се сложат продуктите. Старата заявка пак стига до сървъра, но отговорът ѝ се изхвърля. Отказът спира и самата заявка. Двата начина решават надбягването.
<script setup> спира сам, когато компонентът си отиде, и вика последното onCleanup. Затова заявка, която още пътува, се отказва и при излизане от страницата.Упражнение
Опитай пак
Тук първата заявка за млечните получава 500. Каталогът показва „Продуктите не се заредиха.“, но няма как да опиташ пак. Добави бутон „Опитай пак“, който пуска зареждането наново. Отказът при бързо превключване трябва да остане. Проверката чака грешката, натиска „Опитай пак“, чака „Зареждане…“ и 5 продукта, после превключва бързо на плодовете и напитките.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Млечни' },
{ id: 5, name: 'Плодове и зеленчуци' },
{ id: 6, name: 'Напитки' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Категории">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
const attempt = ref(0);
watch([() => props.categoryId, attempt], async ([categoryId], old, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });
</script>
<template>
<p v-if="status === 'loading'" role="status">Зареждане…</p>
<template v-else-if="status === 'error'">
<p role="alert">Продуктите не се заредиха.</p>
<button type="button" @click="attempt++">Опитай пак</button>
</template>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
const attempt = ref(0), източникът на watch става [() => props.categoryId, attempt], функцията получава [categoryId], а при 'error' шаблонът показва съобщението и бутон с @click="attempt++". Проверката хваща бутон, който сменя attempt, когато attempt го няма в източника: нищо не тръгва.
Проверката хваща и бутон, който само слага 'loading' без нова заявка, и код без проверката на response.ok: тогава отговорът с 500 се чете като списък с продукти.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Props: данни отгоре · Условия: празно, грешка, зарежда · Състояние · Ефекти
- Продължава в: Формуляри в компонент · Общо състояние · Тестове на компоненти · От пясъчника към проект
- Задачи по темата: Ефекти, данни и формуляри