Przejdź do treści

Temat 30 · 2 × 45 min

async/await i fetch

W lekcji 29 spłaszczyłeś piramidę callbacków łańcuchem then. Da się jeszcze lepiej: async/await pozwala pisać kod asynchroniczny tak, jakby był zwykłym, synchronicznym – linia po linii, z try…catch i pętlami. A fetch to funkcja, dla której to wszystko powstało: pobieranie danych z serwera. Na końcu lekcji zbudujesz aplikację pogodową z prawdziwego, publicznego API – pierwszy projekt, który „rozmawia ze światem”.

async i await

Dwa słowa kluczowe, jedna zasada: await czeka na obietnicę i oddaje jej wartość – ale tylko wewnątrz funkcji async, a funkcja async zawsze zwraca obietnicę.

async-podstawy.js
const czekaj = (ms, w) => new Promise((r) => setTimeout(() => r(w), ms));

// ta sama logika, co łańcuch then z lekcji 29 – ale czyta się od góry do dołu
async function pokazZamowienie() {
  console.log('1. start funkcji');
  const uzytkownik = await czekaj(200, { imie: 'Ola' }); // „zatrzymaj TĘ funkcję, aż obietnica się spełni”
  console.log('3. mam użytkownika', uzytkownik.imie);
  const zamowienia = await czekaj(200, [120, 80]);
  console.log('4. suma', zamowienia.reduce((a, b) => a + b));
  return 'gotowe'; // zwrócona wartość staje się wartością obietnicy
}

const wynik = pokazZamowienie();
console.log('2. funkcja zwróciła:', wynik instanceof Promise); // true – reszta programu NIE czeka
wynik.then((w) => console.log('5. obietnica spełniona:', w));
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik

Kolejność w konsoli – 1, 2, 3, 4, 5 – mówi wszystko: await wstrzymuje tylko funkcję, w której stoi. Reszta skryptu idzie dalej (stąd 2 przed 3), a gdy obietnica się spełni, funkcja wznawia pracę jako mikrozadanie. Strona nigdy nie zamiera.

Sprawdź się

Co wypisze ten kod?

async function f() {
console.log('A');
await null;
console.log('B');
}
f();
console.log('C');

Kilka faktów, które warto mieć w głowie:

  • async można dodać do deklaracji, wyrażenia funkcji, strzałki i metody klasy: async () => {}, async pobierz() {}.
  • await na zwykłej wartości (await 5) po prostu ją zwraca – ale wciąż oddaje sterowanie na jedną turę mikrozadań.
  • Poza funkcją await działa tylko w modułach ES (<script type="module">, lekcja 32) – to tzw. top-level await. W zwykłym skrypcie i w naszych przykładach opakuj kod w async function main() {…} i wywołaj main().
  • Funkcja async, która nic nie zwraca, zwraca obietnicę spełnioną undefined.

Błędy: try…catch z await

Odrzucona obietnica, na którą czekasz przez await, zamienia się w wyjątek w miejscu await. Łapiesz go zwykłym try…catch z lekcji 25 – tym samym, którym łapiesz JSON.parse:

try-catch-await.js
const czekaj = (ms, w) => new Promise((r) => setTimeout(() => r(w), ms));
const odrzuc = (ms, powod) => new Promise((_, rej) => setTimeout(() => rej(new Error(powod)), ms));

async function zaloguj(login) {
  console.log(`[${login}] pokaż spinner`);
  try {
    const sesja = await (login === 'ola' ? czekaj(150, { token: 'abc' }) : odrzuc(150, 'Błędne hasło'));
    console.log(`[${login}] zalogowano, token ${sesja.token}`);
    return sesja;
  } catch (blad) {
    console.log(`[${login}] błąd: ${blad.message}`);
    return null; // funkcja „połyka” błąd i zwraca wartość zastępczą
  } finally {
    console.log(`[${login}] ukryj spinner`); // zawsze, jak w then().finally()
  }
}

zaloguj('ola');
zaloguj('bartek');

// bez try…catch wewnątrz – błąd „wypływa” z funkcji async jako odrzucona obietnica
async function bezObslugi() {
  await odrzuc(100, 'nikt mnie nie złapał w funkcji');
}
bezObslugi().catch((e) => console.log('złapane na zewnątrz:', e.message));
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik

Zasada jest ta sama co przy łańcuchach: błąd musi gdzieś trafić. Albo try…catch w funkcji, albo .catch() tam, gdzie ją wywołujesz. Jeśli nie zrobisz żadnego – konsola pokaże Uncaught (in promise), a użytkownik zobaczy wiecznie kręcący się spinner.

Po kolei czy równolegle?

await wykonuje operacje sekwencyjnie: każda czeka na poprzednią. To właściwe, gdy druga potrzebuje wyniku pierwszej. Gdy są niezależne, marnujesz czas – uruchom je razem i poczekaj przez Promise.all:

rownolegle.js
const czekaj = (ms, w) => new Promise((r) => setTimeout(() => r(w), ms));
const pobierzProdukty = () => czekaj(300, ['chleb', 'masło']);
const pobierzKategorie = () => czekaj(300, ['pieczywo', 'nabiał']);

async function poKolei() {
  const start = Date.now();
  const produkty = await pobierzProdukty(); // 300 ms
  const kategorie = await pobierzKategorie(); // +300 ms – rusza dopiero teraz
  console.log('po kolei:', produkty.length, kategorie.length, `~${Math.round((Date.now() - start) / 100) * 100} ms`);
}

async function rownolegle() {
  const start = Date.now();
  // obie obietnice powstają (i startują) w tej samej chwili
  const [produkty, kategorie] = await Promise.all([pobierzProdukty(), pobierzKategorie()]);
  console.log('równolegle:', produkty.length, kategorie.length, `~${Math.round((Date.now() - start) / 100) * 100} ms`);
}

async function main() {
  await poKolei(); // ~600 ms
  await rownolegle(); // ~300 ms
}
main();
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik
petle-async.js
const czekaj = (ms, w) => new Promise((r) => setTimeout(() => r(w), ms));
const zapisz = (id) => czekaj(150, `zapisano ${id}`);

async function main() {
  // po kolei – for…of
  for (const id of [1, 2]) {
    console.log(await zapisz(id)); // każdy czeka na poprzedni
  }
  // równolegle – map + Promise.all
  const wyniki = await Promise.all([3, 4, 5].map((id) => zapisz(id)));
  console.log(wyniki); // ["zapisano 3","zapisano 4","zapisano 5"] po ~150 ms, nie 450
}
main();
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik

fetch – pobieranie danych z sieci

fetch(url, opcje) wysyła żądanie HTTP i zwraca obietnicę obiektu Response. Odczyt treści to druga obietnica: response.json() (parsuje JSON), response.text(), response.blob() (plik). Szkielet, którego będziesz używać w kółko:

fetch-szkielet.js
async function pobierzJson(url) {
  const response = await fetch(url); // 1. czekaj na nagłówki odpowiedzi
  if (!response.ok) {
    // 2. 404, 500… – fetch NIE rzuca błędu, musisz sprawdzić sam
    throw new Error(`HTTP ${response.status} ${response.statusText}`);
  }
  return await response.json(); // 3. czekaj na treść i sparsuj JSON
}

// użycie (wewnątrz funkcji async albo w module ES – tam wolno pisać await na górze pliku)
try {
  const uzytkownik = await pobierzJson('https://jsonplaceholder.typicode.com/users/1');
  console.log(uzytkownik.name);
} catch (blad) {
  // tu trafia: brak sieci (TypeError: Failed to fetch), błąd HTTP z throw powyżej, niepoprawny JSON
  console.error(blad.message);
}

Co jest w Response:

Właściwość / metodaCo dajePrzykład
response.oktrue dla statusów 200–299if (!response.ok) throw …
response.status, statusTextkod i opis HTTP404, 'Not Found'
response.headers.get('Content-Type')nagłówek odpowiedzi'application/json; charset=utf-8'
await response.json()treść jako obiekt/tablicę (rzuca SyntaxError, gdy to nie JSON)dane z API
await response.text()treść jako napisHTML, CSV
await response.blob()treść binarnaobrazek do URL.createObjectURL
response.urlkońcowy adres (po przekierowaniach)

Przykłady w tej lekcji korzystają z publicznych, darmowych API bez klucza: JSONPlaceholder (fikcyjni użytkownicy, posty, zadania), DummyJSON (produkty ze zdjęciami) i Open-Meteo (prawdziwa pogoda). Wszystkie działają w placach zabaw poniżej – zwykłe bloki „Uruchom” w tej lekcji tylko symulują serwer przez setTimeout.

fetch: karta użytkownika z JSONPlaceholder
const karta = document.getElementById('karta');
const btn = document.getElementById('pobierz');
const poleId = document.getElementById('id');

async function pobierzJson(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json(); // w return nie trzeba await – obietnica i tak zostanie rozpakowana
}

async function pokazUzytkownika() {
  btn.disabled = true; // nie pozwól wysłać drugiego żądania w trakcie
  karta.className = 'karta laduje';
  karta.textContent = 'Ładowanie…';
  try {
    const u = await pobierzJson(`https://jsonplaceholder.typicode.com/users/${poleId.value}`);
    karta.className = 'karta';
    karta.innerHTML = `<strong></strong><br>e-mail: <span></span><br>miasto: <span></span><br>firma: <span></span>`;
    // textContent dla danych z zewnątrz – nigdy innerHTML z cudzym tekstem
    const [nazwa, email, miasto, firma] = karta.querySelectorAll('strong, span');
    nazwa.textContent = u.name;
    email.textContent = u.email;
    miasto.textContent = u.address.city;
    firma.textContent = u.company.name;
    console.log('Odpowiedź:', u);
  } catch (blad) {
    karta.className = 'karta blad';
    // wpisz id 11 lub 12, żeby zobaczyć HTTP 404
    karta.textContent = blad.message === 'HTTP 404' ? 'Nie ma takiego użytkownika.' : `Nie udało się pobrać danych (${blad.message}).`;
  } finally {
    btn.disabled = false;
  }
}

btn.addEventListener('click', pokazUzytkownika);
pokazUzytkownika();
Podgląd

Wpisz 11 – zobaczysz obsłużony 404. Wyłącz internet i kliknij – zobaczysz TypeError: Failed to fetch. Oba przypadki trafiają do tego samego catch, a finally zawsze odblokuje przycisk.

Cztery stany interfejsu

Każdy fragment strony, który pobiera dane, ma co najmniej cztery stany, i każdy musi być widoczny dla użytkownika:

StanCo pokazaćTypowy błąd
ładowanietekst „Ładowanie…”, spinner, szkielet; przycisk disabled; aria-busy="true"nic się nie dzieje przez 2 s, użytkownik klika pięć razy
sukcesdane–
pusto„Brak wyników dla…”pusta lista bez wyjaśnienia
błądzrozumiały komunikat + co zrobić („sprawdź połączenie”, „spróbuj ponownie”)[object Object] albo cisza
Lista zadań z API – stany: ładowanie, pusto, błąd
const lista = document.getElementById('lista');
const status = document.getElementById('status');
const btn = document.getElementById('laduj');

async function ladujZadania() {
  const user = document.getElementById('user').value;
  const host = document.getElementById('zepsuj').checked ? 'https://jsonplaceholder.typicode.com/nie-ma-takiego' : 'https://jsonplaceholder.typicode.com/todos';
  const url = new URL(host);
  url.searchParams.set('userId', user); // bezpieczne budowanie ?userId=1 (koduje znaki specjalne)
  url.searchParams.set('_limit', '6');

  // 1. ładowanie
  btn.disabled = true;
  lista.setAttribute('aria-busy', 'true');
  lista.innerHTML = '';
  status.className = '';
  status.textContent = 'Ładowanie…';

  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`Serwer odpowiedział ${response.status}`);
    const zadania = await response.json();

    // 2. pusto
    if (zadania.length === 0) {
      status.textContent = `Użytkownik ${user} nie ma zadań.`;
      return;
    }
    // 3. sukces
    status.textContent = `Zadania użytkownika ${user}: ${zadania.length}`;
    for (const z of zadania) {
      const li = document.createElement('li');
      li.textContent = z.title;
      li.classList.toggle('done', z.completed);
      lista.append(li);
    }
  } catch (blad) {
    // 4. błąd – komunikat dla człowieka, szczegóły do konsoli
    status.className = 'blad';
    status.textContent = 'Nie udało się pobrać zadań. Sprawdź połączenie i spróbuj ponownie.';
    console.error(blad);
  } finally {
    btn.disabled = false;
    lista.removeAttribute('aria-busy');
  }
}

btn.addEventListener('click', ladujZadania);
ladujZadania();
Podgląd

Zwróć uwagę na klasę URL: zamiast sklejać napis '…?userId=' + user + '&_limit=6', ustawiasz parametry przez searchParams.set – znaki specjalne (spacje, &, polskie litery) są kodowane automatycznie. Jeśli musisz skleić ręcznie, owiń wartość w encodeURIComponent(wartosc).

Wysyłanie danych: metoda, nagłówki, treść

Domyślnie fetch robi GET. Żeby coś wysłać, podajesz drugi argument – obiekt opcji:

fetch-post.js
const nowyPost = { title: 'Mój wpis', body: 'Treść…', userId: 1 };

const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }, // mówimy serwerowi, co wysyłamy
  body: JSON.stringify(nowyPost), // body musi być napisem (lub FormData/Blob) – nie obiektem
});
const zapisany = await response.json(); // JSONPlaceholder odpowiada { …nowyPost, id: 101 } – udaje zapis

Metody PUT/PATCH (zmiana) i DELETE (usunięcie) działają tak samo. Wysyłanie formularza bez przeładowania, FormData i pełny CRUD to temat lekcji 31.

POST: dodaj wpis (JSONPlaceholder tylko udaje zapis)
const form = document.getElementById('nowy');
const out = document.getElementById('odpowiedz');

form.addEventListener('submit', async (e) => {
  e.preventDefault(); // zostajemy na stronie
  const dane = Object.fromEntries(new FormData(form)); // { title, body }
  const btn = form.querySelector('button');
  btn.disabled = true;
  out.textContent = 'Wysyłanie…';
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ...dane, userId: 1 }),
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const zapisany = await response.json();
    out.textContent = `Status ${response.status} (Created)\n` + JSON.stringify(zapisany, null, 2);
    form.reset();
  } catch (blad) {
    out.textContent = 'Błąd: ' + blad.message;
  } finally {
    btn.disabled = false;
  }
});
Podgląd

Przerywanie i limit czasu: AbortController

Użytkownik wpisuje w wyszukiwarkę „kla”, potem „klaw”, potem „klawia” – każda litera to żądanie. Odpowiedź na „kla” może przyjść po odpowiedzi na „klawia” i nadpisać wyniki starszymi. Rozwiązanie: anuluj poprzednie żądanie, zanim wyślesz nowe. Do tego służy AbortController – ten sam, który w lekcji 27 odpinał nasłuchiwacze:

abort.js
let kontroler = null;

async function szukaj(fraza) {
  kontroler?.abort(); // anuluj poprzednie – jego fetch odrzuci się z AbortError
  kontroler = new AbortController();
  try {
    const response = await fetch(url, { signal: kontroler.signal });
    // …
  } catch (blad) {
    if (blad.name === 'AbortError') return; // anulowane celowo – to nie błąd, nic nie pokazuj
    pokazBlad(blad);
  }
}

// limit czasu bez własnego timera – gotowy sygnał, który odrzuca po 5 s z TimeoutError
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });

// oba naraz: anulowanie ręczne LUB limit czasu
const signal = AbortSignal.any([kontroler.signal, AbortSignal.timeout(5000)]);

AbortSignal.timeout() i AbortSignal.any() są dostępne we wszystkich aktualnych przeglądarkach od 2024 roku. Rozróżniaj nazwy błędów: AbortError (ktoś wywołał abort()), TimeoutError (minął limit), TypeError (sieć).

Wyszukiwarka produktów z anulowaniem poprzedniego żądania
const pole = document.getElementById('fraza');
const status = document.getElementById('status');
const wyniki = document.getElementById('wyniki');
let kontroler = null;
let timerDebounce = null;

async function szukaj(fraza) {
  kontroler?.abort(); // 1. anuluj poprzednie żądanie, jeśli jeszcze trwa
  kontroler = new AbortController();
  // 2. anulowanie ręczne LUB limit 5 s
  const signal = AbortSignal.any([kontroler.signal, AbortSignal.timeout(5000)]);

  const url = new URL('https://dummyjson.com/products/search');
  url.searchParams.set('q', fraza);
  url.searchParams.set('limit', '6');
  url.searchParams.set('select', 'title,price,thumbnail');

  status.textContent = `Szukam „${fraza}”…`;
  try {
    const response = await fetch(url, { signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const dane = await response.json();

    wyniki.innerHTML = '';
    if (dane.products.length === 0) {
      status.textContent = `Brak wyników dla „${fraza}”.`;
      return;
    }
    status.textContent = `Znaleziono ${dane.total}, pokazuję ${dane.products.length}.`;
    for (const p of dane.products) {
      const li = document.createElement('li');
      const img = document.createElement('img');
      img.src = p.thumbnail;
      img.alt = '';
      const tekst = document.createElement('span');
      tekst.textContent = `${p.title} – ${p.price} $`;
      li.append(img, tekst);
      wyniki.append(li);
    }
  } catch (blad) {
    if (blad.name === 'AbortError') return; // nowsze żądanie już w drodze – ignorujemy stare
    status.textContent = blad.name === 'TimeoutError' ? 'Serwer nie odpowiada – spróbuj później.' : `Błąd: ${blad.message}`;
  }
}

// debounce (lekcja 23): szukaj dopiero 300 ms po ostatnim znaku
pole.addEventListener('input', () => {
  clearTimeout(timerDebounce);
  const fraza = pole.value.trim();
  if (fraza.length < 2) {
    status.textContent = 'Wpisz co najmniej 2 znaki.';
    wyniki.innerHTML = '';
    return;
  }
  timerDebounce = setTimeout(() => szukaj(fraza), 300);
});

pole.value = 'phone';
szukaj('phone');
Podgląd

Dwa mechanizmy współpracują: debounce ogranicza liczbę żądań (nie wysyłaj, dopóki użytkownik pisze), a AbortController sprząta te, które mimo wszystko wyszły i zdążyły się zestarzeć.

CORS – dlaczego niektóre API „nie działają”

Przeglądarka pilnuje zasady tego samego źródła (same-origin): skrypt ze strony twojastrona.pl nie może domyślnie czytać odpowiedzi z innyserwer.pl. Wyjątkiem są serwery, które w odpowiedzi wysyłają nagłówek Access-Control-Allow-Origin (mechanizm CORS, Cross-Origin Resource Sharing) – mówią: „strony z innych domen mogą mnie czytać”. Publiczne API z tej lekcji to robią. Gdy zobaczysz w konsoli blocked by CORS policy i TypeError: Failed to fetch:

  • to nie jest błąd twojego kodu – serwer nie zgadza się na odczyt z przeglądarki (z Postmana czy curl zadziała, bo tam nie ma tej zasady),
  • jeśli piszesz własny backend (np. PHP z INF.03), dodaj nagłówek: header('Access-Control-Allow-Origin: *');,
  • jeśli backend jest obcy – potrzebujesz pośrednika (proxy) na własnym serwerze, który pobierze dane i odda je tobie,
  • żądania z nagłówkiem Content-Type: application/json lub metodą PUT/DELETE wywołują wcześniej „zapytanie wstępne” OPTIONS (preflight) – serwer musi i na nie odpowiedzieć zgodą.

Projekt: aplikacja pogodowa

Łączymy wszystko w aplikację jak z portfolio: pole z nazwą miasta → geokodowanie (nazwa → współrzędne) → prognoza → karta z danymi. Dwa zapytania po kolei (drugie potrzebuje wyniku pierwszego), stany interfejsu, obsługa błędów. API: Open-Meteo (bez klucza, dane z serwisów meteorologicznych).

Pogoda – Open-Meteo
const form = document.getElementById('szukaj');
const status = document.getElementById('status');
const karta = document.getElementById('karta');

// kody pogody WMO używane przez Open-Meteo (wybór najczęstszych)
const OPISY = {
  0: 'bezchmurnie', 1: 'przeważnie bezchmurnie', 2: 'częściowe zachmurzenie', 3: 'pochmurno',
  45: 'mgła', 48: 'mgła osadzająca szadź', 51: 'lekka mżawka', 53: 'mżawka', 55: 'gęsta mżawka',
  61: 'słaby deszcz', 63: 'deszcz', 65: 'ulewny deszcz', 66: 'marznący deszcz', 67: 'silny marznący deszcz',
  71: 'słaby śnieg', 73: 'śnieg', 75: 'intensywny śnieg', 77: 'ziarna śniegu',
  80: 'przelotne opady', 81: 'przelotny deszcz', 82: 'gwałtowne opady', 85: 'przelotny śnieg', 86: 'intensywne opady śniegu',
  95: 'burza', 96: 'burza z gradem', 99: 'silna burza z gradem',
};

async function pobierzJson(url) {
  const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

// krok 1: nazwa → współrzędne
async function znajdzMiasto(nazwa) {
  const url = new URL('https://geocoding-api.open-meteo.com/v1/search');
  url.searchParams.set('name', nazwa);
  url.searchParams.set('count', '1');
  url.searchParams.set('language', 'pl');
  const dane = await pobierzJson(url);
  if (!dane.results || dane.results.length === 0) throw new Error(`Nie znaleziono miejscowości „${nazwa}”`);
  return dane.results[0]; // { name, country, latitude, longitude, admin1 }
}

// krok 2: współrzędne → bieżąca pogoda
async function pobierzPogode({ latitude, longitude }) {
  const url = new URL('https://api.open-meteo.com/v1/forecast');
  url.searchParams.set('latitude', latitude);
  url.searchParams.set('longitude', longitude);
  url.searchParams.set('current', 'temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code');
  url.searchParams.set('timezone', 'auto');
  return pobierzJson(url);
}

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const nazwa = document.getElementById('miasto').value.trim();
  const btn = form.querySelector('button');
  btn.disabled = true;
  karta.hidden = true;
  status.className = '';
  status.textContent = 'Szukam…';

  try {
    const miejsce = await znajdzMiasto(nazwa); // po kolei: pogoda potrzebuje współrzędnych
    status.textContent = 'Pobieram pogodę…';
    const pogoda = await pobierzPogode(miejsce);
    const c = pogoda.current;
    const u = pogoda.current_units ?? {};

    document.getElementById('nazwa').textContent = `${miejsce.name}, ${miejsce.admin1 ?? ''} ${miejsce.country ?? ''}`.replace(/\s+/g, ' ');
    document.getElementById('temp').textContent = `${Math.round(c.temperature_2m)}${u.temperature_2m ?? '°C'}`;
    document.getElementById('opis').textContent = OPISY[c.weather_code] ?? `kod pogody ${c.weather_code}`;
    document.getElementById('szczegoly').textContent =
      `Odczuwalna ${Math.round(c.apparent_temperature)}${u.apparent_temperature ?? '°C'} · ` +
      `wilgotność ${c.relative_humidity_2m}${u.relative_humidity_2m ?? '%'} · ` +
      `wiatr ${Math.round(c.wind_speed_10m)} ${u.wind_speed_10m ?? 'km/h'}`;
    status.textContent = `Dane z ${new Date(c.time).toLocaleString('pl-PL')}`;
    karta.hidden = false;
  } catch (blad) {
    status.className = 'blad';
    status.textContent =
      blad.name === 'TimeoutError' ? 'Serwer pogodowy nie odpowiada – spróbuj ponownie.' : blad.message.startsWith('HTTP') ? `Błąd serwera (${blad.message}).` : blad.message;
    console.error(blad);
  } finally {
    btn.disabled = false;
  }
});

form.requestSubmit(); // pierwsze wyszukanie na starcie
Podgląd

Wpisz „Zakopane”, „Gdańsk”, „Xyzabc” (nie znaleziono) i obserwuj stany. To ten sam szkielet, który Odin Project daje jako projekt „Weather App” – masz go w portfolio.

Ściąga

SkładniaCo robiPrzykład
async function f() {} / async () => {}funkcja zwraca obietnicę; w środku wolno awaitasync function main() {…}
const x = await obietnicawstrzymaj tę funkcję do spełnienia; odrzucenie → wyjątekconst r = await fetch(url)
try { await … } catch (e) {…} finally {…}obsługa błędów jak w kodzie synchronicznymfinally → ukryj spinner, włącz przycisk
await Promise.all([a(), b()])równolegle; destrukturyzacja wynikówconst [x, y] = await Promise.all([…])
for (const x of lista) await f(x)po kolei; nie forEachsekwencyjny zapis
await Promise.all(lista.map(f))równolegle dla całej listypobierz wszystkie
fetch(url) → ResponseGET; odrzuca tylko przy błędzie sieciconst r = await fetch(url)
r.ok, r.status200–299 → ok; sprawdzaj samif (!r.ok) throw new Error(…)
await r.json() / r.text() / r.blob()treść odpowiedzi (druga obietnica!)const dane = await r.json()
new URL(base) + searchParams.set(k, v)bezpieczne parametry ?k=vurl.searchParams.set('q', fraza)
fetch(url, { method, headers, body })POST/PUT/DELETE; body: JSON.stringify(obj) + Content-Typewysyłanie JSON
new AbortController(), { signal }, abort()anulowanie żądania → AbortErrorwyszukiwarka na żywo
AbortSignal.timeout(ms), AbortSignal.any([…])limit czasu → TimeoutError; łączenie sygnałówfetch(url, { signal: AbortSignal.timeout(5000) })
stany: ładowanie / sukces / pusto / błądzawsze widoczne; przycisk disabled na czas żądaniaaria-busy, aria-live
CORSserwer musi zezwolić (Access-Control-Allow-Origin)PHP: header('Access-Control-Allow-Origin: *')

Zadania

Zadanie 1
łatwe

Z then na async/await

Przepisz poniższy łańcuch na funkcję async function raport() z await i try…catch…finally, zachowując identyczne wyjście w konsoli. Potem wywołaj raport() dwa razy równolegle i wypisz, ile łącznie trwało (powinno być ~450 ms, nie ~900).

do-przepisania.js
const czekaj = (ms, w) => new Promise((r) => setTimeout(() => r(w), ms));

czekaj(150, { imie: 'Ola', klasa: '3TP' })
  .then((uczen) => {
    console.log('uczeń:', uczen.imie);
    return czekaj(150, [5, 4, 5]);
  })
  .then((oceny) => {
    const srednia = oceny.reduce((a, b) => a + b) / oceny.length;
    console.log('średnia:', srednia.toFixed(2));
    return czekaj(150, srednia >= 4.75 ? 'z wyróżnieniem' : 'bez wyróżnienia');
  })
  .then((status) => console.log('status:', status))
  .catch((e) => console.error('błąd:', e.message))
  .finally(() => console.log('raport gotowy'));
Pokaż rozwiązanie
raport-async.js
const czekaj = (ms, w) => new Promise((r) => setTimeout(() => r(w), ms));

async function raport() {
  try {
    const uczen = await czekaj(150, { imie: 'Ola', klasa: '3TP' });
    console.log('uczeń:', uczen.imie);
    const oceny = await czekaj(150, [5, 4, 5]);
    const srednia = oceny.reduce((a, b) => a + b) / oceny.length;
    console.log('średnia:', srednia.toFixed(2));
    const status = await czekaj(150, srednia >= 4.75 ? 'z wyróżnieniem' : 'bez wyróżnienia');
    console.log('status:', status);
  } catch (e) {
    console.error('błąd:', e.message);
  } finally {
    console.log('raport gotowy');
  }
}

async function main() {
  const start = Date.now();
  await Promise.all([raport(), raport()]); // oba raporty naraz
  console.log(`dwa raporty w ~${Math.round((Date.now() - start) / 50) * 50} ms`); // ~450, nie ~900
}
main();
Konsola
Kliknij „Uruchom”, aby zobaczyć wynik
Zadanie 2
średnie

Post z autorem i komentarzami

Pole <input type="number" id="postId" value="1" min="1" max="100"> i przycisk „Pokaż”. Po kliknięciu pobierz post https://jsonplaceholder.typicode.com/posts/{id}, a następnie równolegle (Promise.all) autora (/users/{post.userId}) i komentarze (/posts/{id}/comments). Wyświetl tytuł, treść, Autor: imię (e-mail) i listę komentarzy (name pogrubione + body). Obsłuż stany ładowania i błędu (id 101 → 404 → „Nie ma takiego posta”). Zmierz i pokaż czas całej operacji.

Pokaż rozwiązanie
Post + autor + komentarze
const btn = document.getElementById('pokaz');
const status = document.getElementById('status');
const post = document.getElementById('post');
const BASE = 'https://jsonplaceholder.typicode.com';

async function pobierzJson(url) {
  const r = await fetch(url);
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  return r.json();
}

async function pokazPost() {
  const id = document.getElementById('postId').value;
  btn.disabled = true;
  post.hidden = true;
  status.className = '';
  status.textContent = 'Ładowanie…';
  const start = performance.now();
  try {
    // 1. post – potrzebny, by znać userId
    const p = await pobierzJson(`${BASE}/posts/${id}`);
    // 2. autor i komentarze – niezależne od siebie, więc równolegle
    const [autor, komentarze] = await Promise.all([pobierzJson(`${BASE}/users/${p.userId}`), pobierzJson(`${BASE}/posts/${id}/comments`)]);

    document.getElementById('tytul').textContent = p.title;
    document.getElementById('tresc').textContent = p.body;
    document.getElementById('autor').textContent = `Autor: ${autor.name} (${autor.email})`;
    const ul = document.getElementById('komentarze');
    ul.innerHTML = '';
    for (const k of komentarze) {
      const li = document.createElement('li');
      const b = document.createElement('strong');
      b.textContent = k.name;
      li.append(b, document.createElement('br'), k.body);
      ul.append(li);
    }
    post.hidden = false;
    status.textContent = `Pobrano w ${Math.round(performance.now() - start)} ms (${komentarze.length} komentarzy)`;
  } catch (e) {
    status.className = 'blad';
    status.textContent = e.message === 'HTTP 404' ? 'Nie ma takiego posta.' : `Błąd: ${e.message}`;
  } finally {
    btn.disabled = false;
  }
}

btn.addEventListener('click', pokazPost);
pokazPost();
Podgląd
Zadanie 3
średnie

Galeria produktów z kategoriami

Z DummyJSON pobierz listę kategorii (https://dummyjson.com/products/category-list – tablica napisów) do <select>. Po wyborze kategorii pobierz https://dummyjson.com/products/category/{nazwa}?limit=8&select=title,price,thumbnail,rating i wyświetl siatkę kart (miniatura, tytuł, cena, ocena). Wymagania: komunikat ładowania, obsługa błędów, anulowanie poprzedniego żądania przy szybkiej zmianie kategorii (AbortController), select zablokowany podczas ładowania listy kategorii.

Pokaż rozwiązanie
Galeria produktów
const select = document.getElementById('kategoria');
const status = document.getElementById('status');
const siatka = document.getElementById('siatka');
let kontroler = null;

async function pobierzJson(url, signal) {
  const r = await fetch(url, { signal });
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  return r.json();
}

async function zaladujKategorie() {
  try {
    const kategorie = await pobierzJson('https://dummyjson.com/products/category-list');
    select.innerHTML = '';
    for (const k of kategorie) {
      const opt = document.createElement('option');
      opt.value = k;
      opt.textContent = k.replaceAll('-', ' ');
      select.append(opt);
    }
    select.disabled = false;
    await zaladujProdukty(select.value);
  } catch (e) {
    status.className = 'blad';
    status.textContent = 'Nie udało się pobrać kategorii: ' + e.message;
  }
}

async function zaladujProdukty(kategoria) {
  kontroler?.abort();
  kontroler = new AbortController();
  status.className = '';
  status.textContent = `Ładuję „${kategoria}”…`;
  try {
    const url = new URL(`https://dummyjson.com/products/category/${encodeURIComponent(kategoria)}`);
    url.searchParams.set('limit', '8');
    url.searchParams.set('select', 'title,price,thumbnail,rating');
    const dane = await pobierzJson(url, kontroler.signal);

    siatka.innerHTML = '';
    for (const p of dane.products) {
      const karta = document.createElement('div');
      karta.className = 'karta';
      const img = document.createElement('img');
      img.src = p.thumbnail;
      img.alt = p.title;
      img.loading = 'lazy';
      const tytul = document.createElement('strong');
      tytul.textContent = p.title;
      const info = document.createElement('span');
      info.textContent = `${p.price} $ · ★ ${p.rating}`;
      karta.append(img, tytul, info);
      siatka.append(karta);
    }
    status.textContent = `${kategoria}: ${dane.products.length} z ${dane.total} produktów`;
  } catch (e) {
    if (e.name === 'AbortError') return; // zmieniono kategorię – stare żądanie ignorujemy
    status.className = 'blad';
    status.textContent = 'Błąd: ' + e.message;
  }
}

select.addEventListener('change', () => zaladujProdukty(select.value));
zaladujKategorie();
Podgląd
Zadanie 4
trudne

Prognoza na 5 dni

Rozbuduj aplikację pogodową: po znalezieniu miasta pobierz prognozę dzienną (daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max&forecast_days=5&timezone=auto) i pokaż tabelę: dzień tygodnia (po polsku, z toLocaleDateString), opis pogody z kodu WMO, temperatura min/max, prawdopodobieństwo opadów. Dodaj historię wyszukiwań (ostatnie 5 miast) w localStorage (lekcja 25) jako klikalne przyciski. Ten sam AbortController ma anulować całą parę żądań (geokodowanie + prognoza), gdy użytkownik wyszuka ponownie w trakcie.

Pokaż rozwiązanie
Prognoza 5-dniowa z historią
const form = document.getElementById('szukaj');
const pole = document.getElementById('miasto');
const status = document.getElementById('status');
const tabela = document.getElementById('tabela');
const historiaEl = document.getElementById('historia');
const KLUCZ = 'pogoda:historia';
let kontroler = null;

const OPISY = { 0: 'bezchmurnie', 1: 'prawie bezchmurnie', 2: 'częściowe zachmurzenie', 3: 'pochmurno', 45: 'mgła', 48: 'mgła', 51: 'mżawka', 53: 'mżawka', 55: 'mżawka', 61: 'słaby deszcz', 63: 'deszcz', 65: 'ulewa', 71: 'słaby śnieg', 73: 'śnieg', 75: 'śnieżyca', 80: 'przelotne opady', 81: 'przelotne opady', 82: 'gwałtowne opady', 95: 'burza', 96: 'burza z gradem', 99: 'burza z gradem' };

async function pobierzJson(url, signal) {
  const r = await fetch(url, { signal });
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  return r.json();
}

// historia w localStorage – bezpieczny odczyt (lekcja 25)
function czytajHistorie() {
  try {
    return JSON.parse(localStorage.getItem(KLUCZ)) ?? [];
  } catch {
    return [];
  }
}
function zapiszHistorie(miasto) {
  const nowa = [miasto, ...czytajHistorie().filter((m) => m !== miasto)].slice(0, 5);
  try {
    localStorage.setItem(KLUCZ, JSON.stringify(nowa));
  } catch {}
  rysujHistorie();
}
function rysujHistorie() {
  historiaEl.innerHTML = '';
  for (const m of czytajHistorie()) {
    const b = document.createElement('button');
    b.type = 'button';
    b.textContent = m;
    historiaEl.append(b);
  }
}
historiaEl.addEventListener('click', (e) => {
  const b = e.target.closest('button');
  if (!b) return;
  pole.value = b.textContent;
  form.requestSubmit();
});

async function prognoza(nazwa) {
  kontroler?.abort(); // jeden kontroler dla OBU żądań
  kontroler = new AbortController();
  const signal = AbortSignal.any([kontroler.signal, AbortSignal.timeout(8000)]);
  status.className = '';
  status.textContent = 'Szukam miejscowości…';
  tabela.hidden = true;

  try {
    const geo = new URL('https://geocoding-api.open-meteo.com/v1/search');
    geo.searchParams.set('name', nazwa);
    geo.searchParams.set('count', '1');
    geo.searchParams.set('language', 'pl');
    const g = await pobierzJson(geo, signal);
    if (!g.results?.length) throw new Error(`Nie znaleziono „${nazwa}”`);
    const m = g.results[0];

    status.textContent = 'Pobieram prognozę…';
    const api = new URL('https://api.open-meteo.com/v1/forecast');
    api.searchParams.set('latitude', m.latitude);
    api.searchParams.set('longitude', m.longitude);
    api.searchParams.set('daily', 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max');
    api.searchParams.set('forecast_days', '5');
    api.searchParams.set('timezone', 'auto');
    const { daily: d } = await pobierzJson(api, signal);

    document.getElementById('naglowek').textContent = `${m.name}, ${m.country ?? ''}`;
    const tbody = tabela.tBodies[0];
    tbody.innerHTML = '';
    d.time.forEach((dzien, i) => {
      const tr = document.createElement('tr');
      const [r, mies, dd] = dzien.split('-').map(Number);
      const data = new Date(r, mies - 1, dd); // data lokalna, bez przesunięcia UTC (lekcja 28)
      const komorki = [
        data.toLocaleDateString('pl-PL', { weekday: 'short', day: 'numeric', month: 'numeric' }),
        OPISY[d.weather_code[i]] ?? `kod ${d.weather_code[i]}`,
        `${Math.round(d.temperature_2m_min[i])}°`,
        `${Math.round(d.temperature_2m_max[i])}°`,
        `${d.precipitation_probability_max[i] ?? '–'} %`,
      ];
      for (const tekst of komorki) {
        const td = document.createElement('td');
        td.textContent = tekst;
        tr.append(td);
      }
      tbody.append(tr);
    });
    tabela.hidden = false;
    status.textContent = `Prognoza na 5 dni`;
    zapiszHistorie(m.name);
  } catch (e) {
    if (e.name === 'AbortError') return;
    status.className = 'blad';
    status.textContent = e.name === 'TimeoutError' ? 'Serwer nie odpowiada.' : e.message;
  }
}

form.addEventListener('submit', (e) => {
  e.preventDefault();
  prognoza(pole.value.trim());
});
rysujHistorie();
form.requestSubmit();
Podgląd

Test powtórkowy

Test powtórkowy

async/await, fetch, stany i błędy. Jedna poprawna odpowiedź.

0/0poprawnych
Sprawdź się

Co zwraca wywołanie funkcji oznaczonej async, która kończy się return 5?

Sprawdź się

Co wypisze ten kod?

async function f() {
console.log('1');
await Promise.resolve();
console.log('2');
}
console.log('0');
f();
console.log('3');
Sprawdź się

Dlaczego ten kod wypisuje undefined?

async function tytul() {
const r = await fetch('/api/post/1');
const dane = r.json();
console.log(dane.title);
}
Sprawdź się

Serwer odpowiedział statusem 404. Co się stanie w tym kodzie?

try {
const r = await fetch(url);
const dane = await r.json();
console.log('ok');
} catch (e) {
console.log('błąd');
}
Sprawdź się

Które rozwiązanie najszybciej poda wyniki trzech niezależnych zapytań?

Sprawdź się

Jaki błąd dostaniesz w catch, gdy wywołasz kontroler.abort() w trakcie fetch(url, { signal: kontroler.signal })?

Sprawdź się

Które wywołanie poprawnie wysyła obiekt dane jako JSON metodą POST?

Sprawdź się

W konsoli widzisz: „Access to fetch at … has been blocked by CORS policy”. Co to oznacza?

Sprawdź się

Co wypisze ten kod?

async function f() {
try {
  await Promise.reject(new Error('x'));
  console.log('A');
} catch (e) {
  console.log('B');
} finally {
  console.log('C');
}
}
f();
Sprawdź się

Użytkownik pisze w wyszukiwarce „ja”, „jav”, „java”. Odpowiedź na „ja” przychodzi ostatnia i nadpisuje wyniki. Co to naprawi?