Lekcja 15: Asynchroniczny JavaScript - Obietnice (Promises)

W poprzedniej lekcji omówiliśmy funkcje zwrotne (callbacks) jako sposób radzenia sobie z asynchronicznością w JavaScript, ale zauważyliśmy też problem "Callback Hell". Aby rozwiązać ten problem i ułatwić zarządzanie operacjami asynchronicznymi, w ES6 wprowadzono Obietnice (Promises).

Co to jest Obietnica (Promise)?

Obietnica to obiekt reprezentujący ostateczny wynik (lub błąd) operacji asynchronicznej. Zamiast przekazywać callback do funkcji asynchronicznej, funkcja ta zwraca obiekt obietnicy. Do tego obiektu możemy następnie "podpiąć" funkcje, które mają zostać wykonane, gdy operacja się zakończy (sukcesem lub porażką).

Obietnica może znajdować się w jednym z trzech stanów:

  1. Pending (Oczekująca): Stan początkowy, operacja asynchroniczna jeszcze się nie zakończyła.
  2. Fulfilled (Spełniona/Rozwiązana): Operacja asynchroniczna zakończyła się sukcesem. Obietnica przechowuje wynik operacji.
  3. Rejected (Odrzucona): Operacja asynchroniczna zakończyła się błędem. Obietnica przechowuje powód błędu (zazwyczaj obiekt Error).

Obietnica jest obiektem "jednorazowym" - gdy przejdzie ze stanu pending do fulfilled lub rejected, jej stan i wynik (lub błąd) już się nie zmienią.

Tworzenie Obietnicy

Obietnice tworzymy za pomocą konstruktora new Promise(). Konstruktor przyjmuje jako argument funkcję (tzw. "executor"), która sama otrzymuje dwa argumenty: funkcję resolve i funkcję reject.

function symulujPobieranieDanych(czySukces) {
    // Zwracamy nową obietnicę
    return new Promise((resolve, reject) => {
        console.log("Rozpoczynam pobieranie danych (Promise)...");
        setTimeout(() => {
            if (czySukces) {
                const dane = { id: 1, name: "Produkt A" };
                console.log("Dane pobrane pomyślnie.");
                // Operacja udana - rozwiązujemy obietnicę z wynikiem
                resolve(dane);
            } else {
                const blad = new Error("Nie udało się pobrać danych!");
                console.error("Wystąpił błąd podczas pobierania.");
                // Operacja nieudana - odrzucamy obietnicę z błędem
                reject(blad);
            }
        }, 1500); // Symulacja opóźnienia 1.5 sekundy
    });
}

// Użycie funkcji zwracającej Promise
const obietnicaDanych = symulujPobieranieDanych(true);
console.log("Obietnica utworzona, stan: pending", obietnicaDanych);

// Co się stanie po 1.5 sekundy? Obietnica zmieni stan...
// Jak zareagować na zmianę stanu - patrz poniżej.

Konsumowanie Obietnicy (`.then()`, `.catch()`, `.finally()`)

Aby zareagować na zmianę stanu obietnicy, używamy metod dołączanych do obiektu Promise:

// Kontynuacja poprzedniego przykładu

// 1. Użycie .then() z dwoma argumentami
symulujPobieranieDanych(true)
    .then(
        (dane) => {
            console.log("Sukces (.then - onFulfilled):", dane);
        },
        (blad) => {
            console.error("Błąd (.then - onRejected):", blad.message);
        }
    );

// 2. Użycie .then() dla sukcesu i .catch() dla błędu (preferowane)
symulujPobieranieDanych(false) // Tym razem symulujemy błąd
    .then((dane) => {
        // Ten blok się nie wykona, bo obietnica zostanie odrzucona
        console.log("Sukces (.then + .catch):", dane);
    })
    .catch((blad) => {
        // Ten blok obsłuży błąd
        console.error("Błąd (.catch):", blad.message);
    });

// 3. Użycie .finally()
symulujPobieranieDanych(true)
    .then((dane) => {
        console.log("Dane z finally:", dane);
        // Można tu zwrócić coś, co będzie wynikiem kolejnego .then()
        return dane.name;
    })
    .catch((blad) => {
        console.error("Błąd z finally:", blad.message);
        // Można tu rzucić błąd dalej lub zwrócić wartość domyślną
        throw blad; // Rzucenie błędu dalej
    })
    .finally(() => {
        // Ten blok wykona się zawsze, po .then() lub .catch()
        console.log("Operacja zakończona (finally) - czyszczenie zasobów...");
    })
    .then((nazwaProduktu) => {
        // Ten .then() otrzyma wynik z poprzedniego .then() (jeśli nie było błędu)
        console.log("Wynik po finally (jeśli sukces):", nazwaProduktu);
    })
    .catch((blad) => {
        // Ten .catch() złapie błąd rzucony z poprzedniego .catch()
        console.error("Błąd po finally (jeśli błąd):", blad.message);
    });

Łańcuchowanie Obietnic (Promise Chaining)

Ponieważ .then() i .catch() zwracają nowe obietnice, możemy je łączyć w łańcuchy, aby wykonywać sekwencyjne operacje asynchroniczne w bardziej czytelny sposób niż przy użyciu zagnieżdżonych callbacków.

function krokA(wartosc) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("Krok A zakończony");
            resolve(wartosc + 1);
        }, 500);
    });
}

function krokB(wartosc) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("Krok B zakończony");
            resolve(wartosc * 2);
        }, 500);
    });
}

function krokC(wartosc) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (wartosc > 20) {
                console.log("Krok C zakończony");
                resolve(wartosc - 3);
            } else {
                reject(new Error("Wartość w kroku C jest za mała!"));
            }
        }, 500);
    });
}

console.log("Rozpoczynam łańcuch obietnic...");

krokA(10)
    .then((wynikA) => {
        console.log("Wynik kroku A:", wynikA); // 11
        // Zwracamy obietnicę z kroku B
        return krokB(wynikA);
    })
    .then((wynikB) => {
        console.log("Wynik kroku B:", wynikB); // 22
        // Zwracamy obietnicę z kroku C
        return krokC(wynikB);
    })
    .then((wynikC) => {
        console.log("Wynik kroku C:", wynikC); // 19
        console.log("Wszystkie kroki zakończone pomyślnie!");
    })
    .catch((blad) => {
        // Ten blok złapie błąd z dowolnego poprzedniego kroku
        console.error("Wystąpił błąd w łańcuchu:", blad.message);
    })
    .finally(() => {
        console.log("Koniec łańcucha (finally).");
    });

console.log("Łańcuch obietnic zainicjowany.");

Łańcuchowanie obietnic jest znacznie bardziej czytelne niż "Callback Hell".

Zadanie praktyczne

Przepisz funkcję pobierzDaneUzytkownika(id, callback) z poprzedniej lekcji (wersję z obsługą błędu), aby zamiast przyjmować callback, zwracała obiekt Promise. Obietnica powinna być rozwiązana (resolve) z obiektem użytkownika, jeśli ID jest poprawne, lub odrzucona (reject) z błędem, jeśli ID jest niepoprawne (<= 0).

Następnie użyj nowej funkcji, wywołując ją z poprawnym ID i użyj .then() do wyświetlenia danych oraz .catch() do obsłużenia ewentualnego błędu.

Pokaż rozwiązanie
function pobierzDaneUzytkownikaPromise(id) {
    return new Promise((resolve, reject) => {
        console.log(`Pobieranie danych (Promise) dla użytkownika o ID: ${id}...`);
        setTimeout(() => {
            if (id > 0) {
                const daneUzytkownika = {
                    id: id,
                    imie: "Anna",
                    email: "anna@example.com"
                };
                console.log("Dane pobrane (Promise).");
                resolve(daneUzytkownika);
            } else {
                const blad = new Error("Nieprawidłowe ID użytkownika (Promise)");
                console.error("Błąd ID (Promise).");
                reject(blad);
            }
        }, 1000);
    });
}

// Wywołanie z poprawnym ID
pobierzDaneUzytkownikaPromise(456)
    .then(uzytkownik => {
        console.log("--- Dane Użytkownika (Promise) ---");
        console.log(`ID: ${uzytkownik.id}`);
        console.log(`Imię: ${uzytkownik.imie}`);
        console.log(`Email: ${uzytkownik.email}`);
        console.log("---------------------------------");
    })
    .catch(blad => {
        console.error("Błąd pobierania (poprawne ID):", blad.message);
    });

// Wywołanie z niepoprawnym ID
pobierzDaneUzytkownikaPromise(-5)
    .then(uzytkownik => {
        // Ten blok się nie wykona
        console.log("Dane użytkownika (niepoprawne ID):", uzytkownik);
    })
    .catch(blad => {
        console.error("Błąd pobierania (niepoprawne ID):", blad.message);
    });

console.log("Zapytania o dane użytkownika (Promise) wysłane.");

Zadanie do samodzielnego wykonania

Stwórz dwie funkcje zwracające Promise:

  1. pomnozPrzezDwa(liczba): Po 500ms rozwiązuje obietnicę z wynikiem liczba * 2.
  2. dodajPiec(liczba): Po 500ms rozwiązuje obietnicę z wynikiem liczba + 5.

Użyj łańcuchowania obietnic, aby wywołać pomnozPrzezDwa(10), a następnie na wyniku tej operacji wywołać dodajPiec. Wyświetl końcowy wynik w konsoli za pomocą .then(). Dodaj też blok .catch() na końcu łańcucha do obsługi ewentualnych błędów.

FAQ - Asynchroniczny JavaScript - Promises

Czy mogę użyć `await` z Promise?

Tak! Składnia `async/await`, którą poznamy w następnej lekcji, jest zbudowana na bazie Promises i stanowi jeszcze wygodniejszy sposób pracy z nimi, pozwalając pisać kod asynchroniczny w sposób wyglądający bardziej jak kod synchroniczny.

Co jeśli zapomnę dodać `.catch()`?

Jeśli obietnica zostanie odrzucona, a nie ma żadnego bloku .catch() (lub drugiego argumentu w .then()) do obsłużenia tego błędu, spowoduje to tzw. "unhandled promise rejection". W większości środowisk JavaScript (przeglądarki, Node.js) spowoduje to wyświetlenie błędu w konsoli, a w niektórych przypadkach może nawet zakończyć działanie aplikacji (np. w Node.js).

Czy mogę rozwiązać (resolve) lub odrzucić (reject) obietnicę więcej niż raz?

Nie. Obietnica może zmienić stan z pending tylko raz - albo na fulfilled (po wywołaniu resolve), albo na rejected (po wywołaniu reject). Kolejne wywołania resolve lub reject dla tej samej obietnicy są ignorowane.

Co zwraca funkcja wewnątrz `.then()` lub `.catch()`?

Funkcja wewnątrz .then() lub .catch() może zwrócić zwykłą wartość lub inną obietnicę. Jeśli zwróci wartość, staje się ona wynikiem nowej obietnicy zwróconej przez .then()/.catch(). Jeśli zwróci obietnicę, łańcuch poczeka na jej rozwiązanie.

Do czego służą `Promise.all()`, `Promise.race()`, `Promise.allSettled()`?

Są to statyczne metody obiektu `Promise` służące do pracy z wieloma obietnicami naraz. Promise.all() czeka na spełnienie wszystkich obietnic (lub odrzucenie pierwszej). Promise.race() czeka na spełnienie lub odrzucenie pierwszej obietnicy. Promise.allSettled() czeka, aż wszystkie obietnice zakończą działanie (niezależnie od sukcesu czy porażki).

Czy Promises całkowicie eliminują callbacki?

Nie. Chociaż Promises rozwiązują problem "Callback Hell" dla sekwencyjnych operacji asynchronicznych, same metody .then(), .catch() i .finally() przyjmują funkcje zwrotne jako argumenty. Callbacki są nadal fundamentalnym elementem JavaScript, np. w obsłudze zdarzeń.