Данни от API
Накратко: fetch взима данни от API с async/await, проверява response.ok, чете JSON и страницата показва едно от четирите състояния: зарежда, грешка, празно или данни.
Проблем
Празна витрина
Страницата на млечните продукти взима продуктите от сървъра. Днес сървърът има проблем и отговаря с грешка 500. Купувачът вижда заглавие и нищо под него.
Конзола
В конзолата пише products.map is not a function. Кодът е приел отговора на грешката, { "error": "…" }, за списък с продукти. Купувачът не вижда нито грешка, нито „опитай пак“. А и когато сървърът работи, докато отговорът пътува, страницата пак е празна.
Идеята
fetch в страницата
fetch(адрес) праща заявка и връща обещание (Promise) за отговора. С await функцията чака отговора, без да спира страницата:
const response = await fetch('/api/products?category=4');if (!response.ok) throw new Error(`HTTP ${response.status}`);const products = await response.json();fetchхвърля само ако отговор няма: няма мрежа. Отговор с 404 или 500 е отговор, и обещанието се изпълнява.response.okе true само за кодове от 200 до 299. Проверката е твоя работа.response.json()чете тялото и го превръща в обект. Това също е обещание, затова и то е сawait.
В пясъчника адресите /api/… отговаря мнимото API на магазина, без мрежа: GET /api/products (с ?q= и ?category=), /api/products/3, /api/categories, /api/orders и POST /api/orders. Урокът може да го направи бавно, да му каже да отговаря с 500 или да върне празен списък. Така всяко състояние е истинско и винаги еднакво.
Идеята
Четири състояния
Заявката има време и изход. Екранът трябва да казва какво става във всеки момент:
- зарежда: заявката е тръгнала, отговор няма;
- данни: дошъл е списък с продукти;
- празно: дошъл е списък, но празен. Това не е грешка;
- грешка: няма отговор или отговорът не е
ok.
Всяко състояние е елемент в HTML-а. Кодът показва и скрива елементите със свойството hidden:
<p id="loading" role="status">Зареждане…</p><p id="error" role="alert" hidden></p><p id="empty" hidden>Няма продукти в тази категория.</p><ul id="products"></ul>„Зареждане…“ е видимо от самото начало, още преди JavaScript да тръгне. role="status" кара екранния четец да го прочете, а role="alert" прочита грешката веднага, щом се появи.
Виж го
Виж една заявка
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'Продуктите не се заредиха.'; error.hidden = false; } finally { loading.hidden = true; }}Стъпка по стъпка, с думи
- load() праща заявката. Отговор още няма: на екрана е „Зареждане…“ от HTML-а.
- await чака, но страницата не замръзва: купувачът може да превърта, а спинърът се върти. При бавна мрежа това са секунди.
- 200 и масив: ok е true, json() дава продуктите, редовете се рисуват, а finally скрива „Зареждане…“.
- Същата заявка, отговор 500. fetch не хвърля: той е получил отговор. Хвърля нашата проверка на ok, а catch показва грешката.
- 200 и []: това не е грешка. Масивът е празен, затова страницата казва „няма продукти“ вместо празно място.
- Четири състояния, всяко видимо: зарежда, данни, грешка, празно. finally скрива „Зареждане…“ при всеки изход от try.
Същият код, три различни отговора. При 500 fetch не хвърля: хвърля проверката на ok, и catch показва грешката. finally скрива „Зареждане…“ и в трите случая.
Виж го
Бавното API
Тук API-то отговаря след 1,2 секунди. Натисни „Изпълни“ и гледай „Зареждане…“.
Конзола
Целият код на зареждането:
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'Продуктите не се заредиха.'; error.hidden = false; } finally { loading.hidden = true; }}
load();Пробвай
Грешка
Тук първата заявка получава 500, а втората минава. Направи така, че при грешка страницата да покаже „Продуктите не се заредиха.“ в #error и бутона „Опитай пак“, „Зареждане…“ да изчезне, а „Няма продукти“ да не се показва. Бутонът вече зарежда наново: провери ли, след него трябва да се видят продуктите.
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Млечни</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Млечни</h1>
<p id="loading" role="status">Зареждане…</p>
<p id="error" role="alert" hidden></p>
<button id="retry" hidden>Опитай пак</button>
<p id="empty" hidden>Няма продукти в тази категория.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
const retry = document.querySelector('#retry');
retry.addEventListener('click', () => {
retry.hidden = true;
error.hidden = true;
loading.hidden = false;
load();
});
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'Продуктите не се заредиха.';
error.hidden = false;
retry.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
Проверката на response.ok хвърля при 500, catch пише съобщението и показва #error и бутона, а finally скрива „Зареждане…“. Без проверката кодът чете { "error": "…" } като продукти: products.length е undefined, и undefined > 0 е false, затова се показва „Няма продукти“, което е невярно. Проверката натиска „Опитай пак“, защото страница, която показва грешката винаги, също би минала първата половина.
Пробвай
Празно
Тук API-то връща празен списък. Покажи „Няма продукти в тази категория.“, когато продуктите са нула, и го скрий, когато ги има.
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Млечни</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Млечни</h1>
<p id="loading" role="status">Зареждане…</p>
<p id="error" role="alert" hidden></p>
<p id="empty" hidden>Няма продукти в тази категория.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'Продуктите не се заредиха.';
error.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
empty.hidden = products.length > 0;: съобщението е скрито точно когато има поне един продукт. if (!products) не работи: празният масив е „истина“ и !products е false.
Пробвай
Намери грешката
Сървърът отговаря с 200 и продукти, а страницата показва грешка.
Избери реда с грешката.
На ред 5 липсва await. response.json() връща обещание, а не масив. Обещанието няма map, затова ред 6 хвърля и catch показва грешка, макар сървърът да е отговорил.
Идеята
Изпращане: бутонът и чакането
Поръчката тръгва с POST. Данните са в тялото като JSON, а заглавката Content-Type казва на сървъра какво получава:
const response = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, items }),});data е обектът от полетата на формуляра: Object.fromEntries(new FormData(form)) прави от двойките name=value обект { name: …, email: …, address: … }.
Докато отговорът пътува, бутонът „Поръчай“ още е включен. Нетърпелив купувач натиска два пъти:
Конзола
Две заявки, две поръчки: 61 и 62. Страницата показва последната и никой не разбира, че първата също е приета.
Пробвай
Един клик, една поръчка
Изключи бутона „Поръчай“, преди заявката да тръгне, и го включи пак, когато отговорът дойде, дори ако е грешка. Проверката натиска два пъти бързо и очаква една поръчка: 61.
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Бърза поръчка</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Бърза поръчка</h1>
<p>Бял хляб × 2, Прясно мляко × 1</p>
<form id="order">
<label>Име <input name="name" value="Ива Петрова"></label>
<label>Имейл <input name="email" value="iva@example.com"></label>
<label>Адрес <input name="address" value="ул. Шипка 7"></label>
<button>Поръчай</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const order = await response.json();
result.textContent = `Поръчка ${order.id} е приета.`;
} finally {
button.disabled = false;
}
});
button.disabled = true е първото нещо след preventDefault(), преди await. Изключеният бутон не приема клик и не праща формуляра. finally { button.disabled = false; } го включва и при успех, и при грешка. Ако бутонът се изключи след await fetch(…), вторият клик вече е минал.
По-точно, за любопитните
Грешка в мрежата. Без мрежа fetch хвърля TypeError: Failed to fetch. catch хваща и нея, и твоето throw при !response.ok: за купувача и двете са „не се зареди“.
Тялото на грешката. Сървърът често обяснява грешката в тялото, като JSON. При 400 мнимото API връща { "errors": { "email": "…" } }: грешка по поле. Затова в упражнението тялото се чете и при грешка.
Опитай пак. Бутон „Опитай пак“ до грешката просто вика load() наново. Преди това „Зареждане…“ трябва да се покаже отново, а грешката да се скрие.
Състезание между заявки. Ако купувачът смени категорията два пъти бързо, двата отговора може да дойдат в обратен ред и страницата да покаже старата категория. Как се спира стара заявка (AbortController) е в модула за ефекти.
Проверка в браузъра и на сървъра. required и type="email" спират формуляра, преди да тръгне. Сървърът проверява наново, защото заявка може да дойде и без нашата страница. В упражнението полетата са без вградени проверки, за да се види какво отговаря сървърът.
Упражнение
Поръчката
Изпрати поръчката: POST /api/orders с name, email и address от формуляра и items от масива. Докато се праща, бутонът е изключен. При 400 сложи всяко съобщение от errors в абзаца под полето (#email-error и т.н.), а старите съобщения изчисти при всяко ново изпращане. При успех в #result пиши „Поръчка 61 е приета. Общо: 4,00 €“ (номерът и сумата са от отговора).
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Поръчка</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Поръчка</h1>
<p>Бял хляб × 2, Прясно мляко × 1</p>
<p class="error" id="items-error"></p>
<form id="order">
<label>Име <input name="name"></label>
<p class="error" id="name-error"></p>
<label>Имейл <input name="email"></label>
<p class="error" id="email-error"></p>
<label>Адрес <input name="address"></label>
<p class="error" id="address-error"></p>
<button>Поръчай</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
result.textContent = '';
for (const p of document.querySelectorAll('.error')) p.textContent = '';
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const answer = await response.json();
if (response.status === 400) {
for (const [name, message] of Object.entries(answer.errors)) {
document.querySelector(`#${name}-error`).textContent = message;
}
} else if (response.ok) {
result.textContent = `Поръчка ${answer.id} е приета. Общо: ${money(answer.total)}`;
} else {
result.textContent = 'Поръчката не замина. Опитай пак.';
}
} finally {
button.disabled = false;
}
});
Сървърът връща 400 и { errors: { email: …, address: … } }. Цикълът по Object.entries(answer.errors) намира абзаца #${name}-error и пише съобщението. Ако старите съобщения не се чистят, след поправката „Имейлът не е валиден“ остава под вярно поле. При 201 отговорът е { id, status, total }: номерът и сумата идват от сървъра, а не от страницата.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Достъпност · DOM: страницата от JavaScript · Събития · Списък от данни
- Продължава в: Условия: празно, грешка, зарежда · Данни от API в компонент · Формуляри в компонент · Витрината на магазина
- Задачи по темата: JavaScript в страницата