Asynchroniczność w JavaScript cz. 3: Promise API
Asynchroniczność w JavaScript cz. 3: Promise API
Dzisiaj pora na kolejny artykuł z serii Asynchroniczność w JavaScript. W poprzedniej części opisaliśmy mechanizm funkcji zwrotnych, czyli tzw. callbacków. Jeżeli udało Ci się z nią zapoznać, to pewnie kojarzysz problemy, z jakimi można się zetknąć w trakcie ich użycia. Wspomnieliśmy tam również, że twórcy języka wyciągnęli do nas pomocną dłoń. Tą pomocną dłonią jest następca callbacków, czyli Promise API. W tym artykule przedstawimy, czym jest, jak działa oraz jak wykorzystać je w praktyce.
Co nam obiecano?
Promise API to jedna z rewolucyjnych funkcjonalności wprowadzonych do JavaScript w standardzie ES6. Myślą, jaka przyświecała twórcom, było ułatwienia zarządzania asynchronicznymi operacjami takimi jak np. pobieranie danych z API lub przetwarzanie dużych plików. Do tego podjęto próbę rozwiązania bolączek, które towarzyszyły użyciu callbacków.
Generalnie, jest to obiekt zwracający pojedynczą wartość będącą wynikiem asynchronicznej operacji zakończonej sukcesem lub porażkę. Może znajdować się w trzech stanach:
- pending (oczekujący) stan początkowy, w którym promise nie został jeszcze spełniony ani odrzucony.
Spójrzmy na przykład poniżej:
const myPromise = new Promise((resolve, reject) => { setTimeout(() => { resolve("Obietnica spełniona!"); }, 3000); }); console.log("Obietnica w trakcie wykonywania...");
Nasz promise wykona się dopiero po 3 sekundach, do tego czasu znajduje się w stanie oczekującym.
- fulfilled (spełniony) stan, w którym obietnica została spełniona. Oznacza to, że asynchroniczna operacja zakończyła się sukcesem.
Ponownie posłużmy się przykładem:
const myPromise = new Promise((resolve, reject) => { setTimeout(() => { resolve("Obietnica spełniona!"); }, 3000); }); myPromise.then((result) => { console.log(result); });
Po 3 sekundach promise zostanie spełniony. Spowoduje to wypisanie na konsole Obietnica spełniona!. W celu pobrania wyniku operacji zakończonej sukcesem musimy użyć metody then().
- rejected (odrzucony) stan, w którym promise został odrzucony. Oznacza to, że asynchroniczna operacja zakończyła się niepowodzeniem, a nam zostanie zwrócony błąd.
Szybki rzut oka:
const myPromise = new Promise((resolve, reject) => { setTimeout(() => { reject("Obietnica odrzucona!"); }, 3000); }); myPromise.catch((error) => { console.log(error); });
Tym razem po 3 sekundach promise zostanie odrzucony. Spowoduje to zwrócenie błędu z komunikatem Obietnica odrzucona!. Do obsłużenia błędu potrzebujemy metody catch().
Kiedy wywołujemy asynchroniczną operację, zamiast blokować interfejs użytkownika, zwracany jest promise. Dzięki temu, że operacja jest wykonywana w tle, aplikacja może kontynuować działanie. W momencie, gdy wynik jest gotowy, obietnica zmienia swój stan.
Obietnice są niezwykle przydatne. Pozwalają na bardziej intuicyjne zarządzanie asynchronicznymi operacjami w przeciwieństwie do tradycyjnego podejścia z użyciem callbacków.
Jak korzystać z obietnic w praktyce?
Aby korzystać z obietnic w praktyce, musimy zrozumieć, jak mechanizm ich działania. Opiera się on na metodach then() oraz catch() oraz finally(). Do metody then() przekazujemy funkcję, która zostanie wykonana, gdy obietnica zostanie spełniona (resolve). Analogiczna sytuacja jest w przypadku metody catch(). Przekazana funkcja zostanie wykonana, gdy obietnica zostanie odrzucona (reject). Metoda finally() jest przydatna w przypadku, gdy chcemy wykonać pewne czynności, niezależnie od wyniku obietnicy, na przykład zamykanie plików lub zatrzymywanie animacji. Bez względu na to, czy obietnica została spełniona, czy odrzucona, kod w metodzie finally() zostanie wykonany. Nie jest używana tak często, jak then() oraz catch(), w pewnych przypadkach może okazać się bardzo przydatna. Zapewnia bardziej kontrolowane zamykanie procesów związanych z asynchronicznym kodem.
Najlepiej będzie to pokazać na przykładzie:
fetch('https://jsonplaceholder.typicode.com/todos/1') .then(response => response.json()) .catch(error => console.error(error)) .finally(() => console.log('Pobieranie danych zakończone'));
W powyższym przykładzie użyliśmy fetch API, które pozwala na pobranie danych z serwera. Nasz strzał został wywołany pod fake API wystawione na JSONplaceholder. Ponieważ funkcja fetch() zwraca obietnice możemy użyć na niej metody then() do pobrania zawartości z response’a. W funkcji, która jest wywołana w then() mamy przetwarzanie przetwarza odpowiedzi z serwera na format JSON. Jeśli wystąpi błąd, funkcja catch() wyświetli informację o błędzie. Natomiast finally() w tym przypadku niezależnie od wyniku wyświetli nam napis Pobieranie danych zakończone na konsole.
Promise API pozwala również na łańcuchowe wywołanie tzn. jeżeli np. mamy kilka operacji asynchronicznych, które muszą wykonać się sekwencyjnie jedna po drugiej, możemy przekazać wynik zakończonego promise’a do kolejnego. Rozszerzmy przykład wyżej o wyświetlenie wyniku w konsoli:
fetch('https://jsonplaceholder.typicode.com/todos/1') .then(response => response.json()) .then(data => console.log(data)) .catch(error => console.error(error)) .finally(() => console.log('Pobieranie danych zakończone'));
Aby przekazywać dane między wywołaniami Promise API, należy je zwracać z każdej funkcji then() lub catch(). Wartość zwrócona z tych funkcji jest przekazywana jako argument do następnej funkcji then() w łańcuchu tak jak w przykładzie powyżej.
Promise’y mogą być również zagnieżdżone wewnątrz siebie, co pozwala na tworzenie bardziej skomplikowanych struktur asynchronicznych np.:
fetch('https://jsonplaceholder.typicode.com/todos/1') .then(response => response.json()) .then(data => { return new Promise((resolve, reject) => { // Przetwarzanie danych przy użyciu kolejnego Promise if (data.completed) { resolve('Zadanie zakończone'); } else { reject('Zadanie w trakcie realizacji'); } }); }) .then(message => console.log(message)) .catch(error => console.error(error)) .finally(() => console.log('Pobieranie danych zakończone'));
W powyższym przykładzie, po pobraniu danych z serwera przy użyciu fetch, tworzymy kolejny Promise, który przetwarza dane. Jeśli dane są oznaczone jako ukończone, Promise zostanie rozwiązany i wyświetlimy komunikat Zadanie zakończone. W przeciwnym razie Promise zostanie odrzucony i wyświetlimy komunikat Zadanie w trakcie realizacji.
Używając obietnic, ważne jest, aby zapewnić obsługę błędów dla każdej obietnicy. Jeżeli zostanie rzucony błąd i nie będzie on obsłużony, może to spowodować zatrzymanie działania aplikacji. Dlatego też warto korzystać z metody catch(), która pozwala na łatwe i intuicyjne zarządzanie błędami.
Dobrą praktyką jest również używanie metody finally() tam, gdzie ma to uzasadnienie. Przykładowo, jeśli wykonujemy operacje na plikach lub połączeniach sieciowych, możemy wykorzystać ją do zwalniania zasobów po zakończeniu operacji, niezależnie od wyniku. Możemy również wykonać w niej zatrzymanie animacji lub czyszczenia stanu aplikacji.
Zaawansowane Obietnice
Oprócz podstawowych metod API Promise’ów udostępnia również metody, które pozwalają na bardziej zaawansowane operacje. Do takich metod należą all(), race(), allSettled().
- all()
Metoda all() przyjmuje jako argument tablicę promise’ów. Jest ona przydatna w momencie, gdy potrzebujemy poczekać na wynik kilku ciężkich asynchronicznych operacji np. ładowanie listy plików. Jeżeli chcieli wykonać przekazane promise’y bez użycia metody all, wtedy wykonywałyby się jeden po drugim. Natomiast przy jej użyciu wykonują się równolegle, co daje nam dużą oszczędność czasu. Gdy wszystkie przekazane obietnice zostaną rozwiązane bez błędów ,metoda ta zwraca sukces. W przypadku, jeżeli choć jedna z operacji zakończy się niepowodzeniem, wynikiem all() będzie odrzucona obietnica. Tablica wyników będzie miała rezultaty w tej samej kolejności, w jakiej przekazaliśmy promisy bez względu na ich kolejność zakończenia.
Spójrzmy na przykład użycia:
const promise1 = Promise.resolve(1); const promise2 = Promise.resolve(2); const promise3 = Promise.resolve(3); Promise.all([promise1, promise2, promise3]) .then(values => console.log(values)) .catch(error => console.log(error));
W tym przykładzie metoda all() przyjmuje trzy obietnice z wartościami 1, 2 i 3. Po spełnieniu wszystkich trzech obietnic wartości zostaną przekazane do funkcji then() jako tablica [1, 2, 3] i zostaną wypisane w konsoli.
- allSettled()
Metoda allSettled() działa podobnie jak metoda all(), ale zwraca wynik dla wszystkich przekazanych argumentów. Bez znaczenia czy zostaną spełnione, czy odrzucone. Wynik jest zwracany w postaci tablicy obiektów zawierającej informacje o rezultacie każdej z obietnic.
Ponownie posłużmy się przykladem:
const promise1 = Promise.resolve(1); const promise2 = Promise.reject(new Error('Błąd obietnicy')); const promise3 = Promise.resolve(3); Promise.allSettled([promise1, promise2, promise3]) .then(results => { results.forEach(result => { if (result.status === 'fulfilled') { console.log(`Wartość: ${result.value}`); } else { console.error(`Błąd: ${result.reason.message}`); } }); }) .catch(error => console.error(error));
W powyższym kodzie otrzymujemy listę wyników dla wszystkich obietnic, niezależnie od tego, czy zostały zakończone sukcesem, czy niepowodzeniem. Wynikami są obiekty z polami value i reason. Na podstawie wartości z pola status sprawdzamy, czy obietnica została spełniona, czy odrzucona. Jeżeli została spełniona, to jej wartość wpisujemy odwołując się do value. W przypadku odrzuconej obietnicy robimy to za pomocą reason.
- race()
Metoda race() w przeciwieństwie do dwóch poprzednich nie czeka na zakończenie wszystkich przekazanych obietnic. W momencie, gdy pierwsza z nich się skończy, od razu zwróci nam jej rezultat. Nie bez powodu nazywa się race, w końcu mamy tutaj do czynienia z pewnego rodzaju wyścigiem promise’ów.
Spójrzmy na przykład użycia:
const firstPromise = new Promise((resolve, reject) => { setTimeout(() => { resolve('Obietnica 1 spełniona!'); }, 3000); }); const secondPromise = new Promise((resolve, reject) => { setTimeout(() => { resolve('Obietnica 2 spełniona!'); }, 2000); }); Promise.race([firstPromise, secondPromise]) .then(result => console.log(result)) .catch(error => console.log(error));
W tym przykładzie metoda race() przyjmuje dwa argumenty firstPromise oraz secondPromise. Pierwszy z nich zostanie spełniony po 3, a drugi po 2 sekundach. W tym przypadku obietnica spełniona jako pierwsza to secondPromise więc funkcja then() wypisze Obietnica 2 spełniona! w konsoli.
Async/Await
Async/await to nowsze, bardziej wyrafinowane podejście do obsługi asynchronicznych operacji w JavaScript, które opiera się na obietnicach. Pozwala ono na jeszcze bardziej czytelny i łatwiejszy do zrozumienia kod w porównaniu do standardowych promise’ów. Tak naprawdę jest to tylko sugar syntax. Pod spodem niczym nie różni się od klasycznego Promise API.
Aby użyć async/await, musimy oznaczyć funkcję jako asynchroniczną, dodając przed nią słowo kluczowe async. Spowoduje to, że tak oznaczona funkcja, zwraca obiekt Promise.
A co jeżeli funkcja nie ma słowa kluczowego return, a została oznaczona jako async? Wtedy zwróci nam Promise(undefined).
Zamiast używać metody then(), możemy użyć słowa kluczowego await. A co z obsługą błędów? Możemy to zrobić na dwa sposoby:
- z użyciem bloku try/catch
const getData = async () => { try { const response = await fetch('https://jsonplaceholder.typicode.com/todos/1'); const data = await response.json(); console.log(data); } catch (error) { console.error(error); } } getData();
W powyższym przykładzie funkcja getData jest oznaczona jako async, czyli dopóki operacja za słowem await się nie zakończy, to kod w metodzie nie wykona się dalej. Wewnątrz funkcji znajduje się operacja pobrania danych z serwera, co zostało oznaczone instrukcją await. Następnie korzystamy z instrukcji await ponownie, aby oczekiwany response zamienić na obiekt JSON. Gdyby w trakcie pobierania wystąpił błąd, zostanie on złapany w bloku catch i wyświetlony w konsoli.
- handler catch()
const getData = async () => { const response = await fetch('https://jsonplaceholder.typicode.com/todos/1').catch( (error) => console.error(error) ); const data = await response.json(); console.log(data); } getData();
Kod z podpiętym handlerem catch zadziała w taki sam sposób jak ten z użyciem bloku try/catch. Który sposób wybrać? Oba podejścia są poprawne, także jest to raczej kwestia gustu.
Podsumowanie
Promise API to funkcjonalność, która zdecydowanie była game changerem w momencie wejścia wraz z ES6. Umożliwiają programistom łatwiejsze zarządzanie asynchronicznymi operacjami w kodzie. Ich użycie pozwala tworzyć bardziej czytelny i zrozumiały kod, który jest łatwiejszy w utrzymaniu i debugowaniu.
Podobne Tematy
- Asynchroniczność w JavaScript cz. 1: Przetwarzanie synchroniczne i asynchroniczne
- Asynchroniczność w JavaScript cz. 2: Callbacks
- Asynchroniczność w JavaScript cz. 4: Event Loop