Ostatnimi czasy coraz większą popularność w pracy z kodem asynchronicznym zdobywają tzw. obietnice. Pozostawiają większe pole do działania w zakresie

Ostatnimi czasy coraz większą popularność w pracy z kodem asynchronicznym zdobywają tzw. obietnice. Pozostawiają większe pole do działania w zakresie kontroli synchronizacji, obsługi błędów i czytelności kodu, niż funkcje zwrotne.

Czym są obietnice?

  Obietnice (ang. promise) to obiekty reprezentujące wartości, które będzie można wykorzystać w przyszłości. Przy ich użyciu można przechwycić wynik operacji asynchronicznej, np. zdarzenia, i obsłużyć ją w jednolity sposób. W odróżnieniu od technik obsługi zdarzeń bazujących na funkcjach zwrotnych obietnice gwarantują programiście otrzymanie wyników, nawet jeśli zdarzenie zachodzi, zanim zostanie zarejestrowana procedura jego obsługi (inaczej niż w przypadku zdarzeń, które mogą powodować wyścigi), oraz umożliwiają przechwytywanie i obsługiwanie wyjątków. Ponadto przy użyciu obietnic można pisać bardziej czytelny kod asynchroniczny. Obietnice są znane już od pewnego czasu i były używane przez programistów JavaScriptu w formie bibliotek. Ich definicję zawiera specyfikacja o nazwie Promise/A+ i zostały zaimplementowane w takich bibliotekach, jak Q, When.js czy RSVP.js i jQuery, a także są obsługiwane przez frameworki, np. AngularJS. Choć różne implementacje obietnic spełniają standardowe wymogi, ich interfejsy API różnią się między sobą.

Podejmowano już kilka prób zdefiniowania jednolitej specyfikacji obietnic w języku JavaScript. Najbardziej znane zostały opublikowane przez społeczność CommonJS pod nazwami Promise/A, Promise/B i Promise/D. W standardzie ECMAScript 6 zaimplementowano specyfikację Promise/A+, którą można znaleźć pod adresem: https://promisesaplus.com/.

Na szczęście w ECMAScripcie 6 obietnice wprowadzono jako obiekty macierzyste, dzięki czemu możemy korzystać z jednolitego API do obsługi kodu asynchronicznego, o czym przekonasz się w następnych sekcjach.  

Terminologia z zakresu obietnic

  Zanim przejdziemy do pisania kodu asynchronicznego przy użyciu obietnic, musimy poznać kilka pojęć, których będziemy często używać. Zaczniemy od stanów, w jakich obietnica może się znajdować:

  • Rozwiązana lub spełniona (ang. resolved lub fulfilled) — obietnica jest rozwiązana lub spełniona, gdy reprezentowana przez nią wartość jest dostępna, tzn. powiązane z nią zadanie asynchroniczne zwraca żądaną wartość.
  • Odrzucona (ang. rejected) — obietnica jest odrzucona, gdy związane z nią zadanie asynchroniczne nie zwraca wartości, np. z powodu wyjątku albo ponieważ zwrócona wartość jest nieprawidłowa.
  • Oczekująca (ang. pending) — w tym stanie obietnica pozostaje do czasu rozwiązania lub odrzucenia, tzn. od czasu wysłania żądania rozpoczęcia zadania asynchronicznego do otrzymania wyniku.

  Obietnica, która została rozwiązana lub odrzucona, staje się załatwiona (ang. settled). Ponieważ obietnica może zostać załatwiona tylko raz i obcy konsumenci nie mogą zmieniać jej stanu, obietnica jest niezmienna.  

Tworzenie obietnic

  Znając podstawową terminologię dotyczącą obietnic, możemy przejść do technik ich tworzenia oraz posługiwania się nimi w celu sprawniejszej obsługi kodu asynchronicznego. W przykładach używam składni ES6, ponieważ jest opisana w standardzie i można przewidywać, że w przyszłości wszystkie starsze biblioteki znikną lub się do niej dostosują. Obietnicę można utworzyć za pomocą konstruktora Promise(): [sourcecode language="javascript"] var promise = new Promise(handler); [/sourcecode] Konstruktor Promise() pobiera jako argument funkcję, której zadaniem jest stwierdzenie, czy dana obietnica została spełniona, czy też należy ją odrzucić. Typowa struktura takiej funkcji wygląda następująco: [sourcecode language="javascript"] var promise = new Promise(function(resolve, reject) { if (condition) { // jakiś warunek resolve(value); // rozwiązanie obietnicy } else { reject(reason); // odrzucenie obietnicy i podanie przyczyny } }); [/sourcecode] W parametrach do funkcji obsługującej obietnicę przekazuje się dwie funkcje. Pierwsza z nich (w tym przykładzie nazwana resolve) to funkcja, która ma być wywoływana, gdy wartość zwrócona przez zadanie asynchroniczne stanie się dostępna. Ta zwrócona wartość zostaje przekazana do funkcji resolve. Drugi parametr (w tym przykładzie nazwany reject) jest funkcją, która ma zostać wywołana, gdy obietnica nie może zostać spełniona, np. z powodu błędu albo pojawienia się nieprawidłowej wartości. Kiedy obietnica zostanie odrzucona, do funkcji reject przekazywany jest powód (reason), np. wyjątek. W ramach konkretnego przykładu utworzenia obietnicy przebudujemy naszą starą funkcję httpGet(): [sourcecode language="javascript"] function httpGet(url) { return new Promise(function(resolve, reject) { var httpReq = new XMLHttpRequest(); httpReq.onreadystatechange = function() { var data; if (httpReq.readyState == 4) { if (httpReq.status == 200) { data = JSON.parse(httpReq.responseText); resolve(data); } else { reject(new Error(httpReq.statusText)); } } }; httpReq.open("GET", url, true); httpReq.send(); }); } [/sourcecode] W tej wersji funkcja httpGet() pobiera tylko jeden argument — adres URL, który powinien odpowiedzieć na nasze żądanie HTTP. Funkcja ta zwraca obietnicę, której handler jest dość podobny do jej poprzedniej wersji. Tworzy żądanie HTTP przy użyciu obiektu XMLHttpRequest w normalny sposób, ale dodatkowo wywołuje funkcję resolve(), gdy otrzyma od serwera odpowiedź pomyślną, oraz reject(), gdy otrzyma odpowiedź niepomyślną. W pierwszym przypadku treść odpowiedzi jest przekazywana do funkcji resolve(), w drugim przekazywany jest wyjątek ze statusem HTTP do funkcji reject(). Teraz funkcja httpGet() zwraca obietnicę zamiast bezpośrednio wysyłać żądanie HTTP do serwera.  

Używanie obietnic

  Ponieważ obietnica jest obiektem, można się nią posługiwać jak każdym obiektem, tzn. można ją zapisać w zmiennej, przekazać jako parametr, zwrócić przez funkcję itd. Na przykład obietnicę zwróconą przez funkcję httpGet() moglibyśmy zapisać w zmiennej, jak pokazano w poniższym kodzie: [sourcecode language="javascript"] var myPromise = httpGet("/users/12345") [/sourcecode] Aby użyć obietnicy, tzn. wykorzystać reprezentowaną przez nią wartość, należy wywołać jej metodę then(). Do tej metody przekazuje się funkcję, która otrzyma wartość obietnicy w chwili, gdy ta stanie się dostępna. Spójrzmy na uproszczoną wersję poprzedniego przykładu. Za pomocą nowej funkcji httpGet() zbudowanej na podstawie obietnic pobieramy dane użytkownika i uzyskujemy dostęp do jego bloga. W tym celu posługujemy się poniższym kodem: [sourcecode language="javascript"] httpGet("/users/12345") .then(function(user) { console.log("Identyfikator bloga użytkownika: " + user.blogId); }); [/sourcecode] W tym kodzie przekazujemy do metody then() funkcję, której argument user zostanie związany z danymi asynchronicznie przesłanymi przez serwer. Kiedy dane staną się dostępne, obietnica będzie spełniona i nastąpi wywołanie funkcji. Aby wyświetlić listę wpisów na blogu użytkownika, możemy posłużyć się poniższym kodem: [sourcecode language="javascript"] httpGet("/users/12345") .then(function(user) { httpGet("/blogs/" + user.blogId) .then(function(blog) { displayPostList(blog.posts); }); }); [/sourcecode] W tym przypadku funkcja konsumująca rozwiązaną obietnicę tworzy kolejną obietnicę w celu pobrania wpisów z bloga. W efekcie otrzymujemy zagnieżdżone obietnice, które zostaną rozwiązane po kolei, jedna po drugiej. Ostatecznie ten kod jest podobny do przedstawionego wcześniej rozwiązania z użyciem funkcji zwrotnych. Jednak wersja oparta na obietnicach zapewnia większą elastyczność i jak się wkrótce przekonasz, daje programiście szersze pole do działania. Zaczniemy od przepisania poprzedniego kodu w czytelniejszy sposób: [sourcecode language="javascript"] function getUserData() { return httpGet("/users/12345"); } function getBlog(user) { return httpGet("/blogs/" + user.blogId); } function displayBlog(blog) { displayPostList(blog.posts); } getUserData() .then(function(user) { getBlog(user) .then(function(blog) { displayPostList(blog.posts); }) }) [/sourcecode] Aby poprawić czytelność kodu, nadaliśmy funkcjom nazwy, ale to nie wszystko, co możemy zrobić. Metoda then() zawsze zwraca nową obietnicę, więc możemy stworzyć łańcuch wywołań zaznaczony pogrubieniem w poniższym przykładzie: [sourcecode language="javascript"] function getUserData() { return httpGet("/users/12345"); } function getBlog(user) { return httpGet("/blogs/" + user.blogId); } function displayBlog(blog) { displayPostList(blog.posts); } getUserData() .then(getBlog) .then(displayBlog); [/sourcecode] W tym przykładzie funkcje getUserData() i getBlog() zwracają obietnice utworzone przez funkcję httpGet(). Budowa takiego łańcucha wywołań była możliwa dzięki użyciu metody then() zwracającej te obietnice. Należy jednak wiedzieć, że metoda then() zawsze zwraca obietnicę, nawet gdy handler nie tworzy i nie zwraca jej bezpośrednio. Kiedy handler obietnicy nie zwraca obietnicy, tylko standardową wartość, np. podstawową albo obiekt, metoda then() tworzy nową obietnicę i rozwiązuje ją ze zwróconą wartością. Jeśli handler obietnicy nic nie zwraca, metoda then() i tak tworzy nową rozwiązaną obietnicę, którą zwraca.  

Przechwytywanie błędów

  W poprzednich przykładach wartość zwróconą przez obietnicę pobieraliśmy za pomocą metody then(). Jednak w razie gdyby coś się nie udało, obietnica może też zostać odrzucona. Należy wiedzieć, że odrzucenie obietnicy może nastąpić zarówno w wyniku odrzucenia bezpośredniego, jak i po wystąpieniu błędu w funkcji zwrotnej konstruktora. Przypadek odrzucenia obietnicy można obsłużyć, przekazując do metody then() drugą funkcję. W związku z tym nasz poprzedni kod mógłby wyglądać tak: [sourcecode language="javascript"] function getUserData() { return httpGet("/users/12345"); } function getBlog(user) { return httpGet("/blogs/" + user.blogId); } function displayBlog(blog) { displayPostList(blog.posts); } function manageError(error) { console.log(error.message); } getUserData() .then(getBlog, manageError) .then(displayBlog, manageError); [/sourcecode] Dodaliśmy funkcję manageError(), której zadaniem jest wyświetlanie powiadomienia o błędzie w konsoli. Funkcję tę przekazujemy jako drugi parametr do metody then() i zostanie ona wykonana w przypadku odrzucenia obietnicy. W tym przykładzie odrzucenie każdej obietnicy obsługujemy za pomocą tej samej funkcji, ale oczywiście nic nie stoi na przeszkodzie, aby dla każdej obietnicy utworzyć osobną funkcję. Wszystko zależy od tego, co jest potrzebne. Wiemy już, że drugi argument metody then() nie jest obowiązkowy. Pierwszy argument też jest opcjonalny. Można nawet w jego miejsce przekazać null, co oznacza, że interesuje nas tylko obsługa odrzuceń obietnicy. Możemy np. napisać taki kod: [sourcecode language="javascript"] getUserData() .then(null, manageError); [/sourcecode] Ten kod zignoruje rozwiązaną obietnicę i będzie obsługiwał tylko błędy. W tym konkretnym przypadku takie rozwiązanie nie ma praktycznego zastosowania, ale za pomocą tej techniki można np. oddzielić obsługę spełnionych obietnic od ich odrzuceń, jak w poniższym przykładzie: [sourcecode language="javascript"] getUserData() .then(getBlog) .then(null, manageError); [/sourcecode] Dzięki mechanizmowi łańcuchowego wywoływania metod odrzucona obietnica jest przekazywana od jednego wywołania metody then() do następnego. Kiedy dojdzie do odrzucenia obietnicy, gdy nie jest wyznaczona funkcja obsługująca odrzucenie, następuje utworzenie nowej obietnicy z takim samym powodem odrzucenia i przekazanie jej do następnej metody then() w celu obsłużenia. Dzięki takiemu sposobowi propagacji błędów, jeśli wszystkie błędy mają być obsługiwane w taki sam sposób, wystarczy utworzyć tylko jedną funkcję obsługi odrzuceń. W związku z tym poprzedni przykład możemy przepisać następująco: [sourcecode language="javascript"] getUserData() .then(getBlog) .then(displayBlog) .then(null, manageError); [/sourcecode] W tym przypadku funkcja manageError() obsłuży wszystkie odrzucenia, jakie wystąpią w którymkolwiek miejscu tego łańcucha obietnic. Zamiast wartości null w pierwszym parametrze metody then() można użyć składni opartej na metodzie catch() obiektu obietnicy. Poniższy kod jest równoważny z poprzednim: [sourcecode language="javascript"] getUserData() .then(getBlog) .then(displayBlog) .catch(manageError); [/sourcecode] Jak widać, używając obietnic, można tworzyć nie tylko czytelniejszy, ale i bardziej niezawodny kod, ponieważ zyskujemy możliwość przechwytywania i obsługiwania błędów asynchronicznych.  

Kompozycje obietnic

  Przedstawiony przykład użycia obietnic jest uproszczoną wersją przykładu zawartego w opisie techniki z użyciem funkcji zwrotnych. W tym przypadku skupiliśmy się tylko na pokazywaniu wpisów z bloga użytkownika, a pominęliśmy wyświetlanie zdjęć. Przypomnijmy sobie oryginalny kod: [sourcecode language="javascript"] function getUserBlogAndPhoto(user) { getBlog(user.blogId); getPhotos(user.albumId); } function getBlog(blogId) { httpGet("/blogs/" + blogId, displayBlog); } function displayBlog(blog) { displayPostList(blog.posts); } function getPhotos(albumId) { httpGet("/photos/" + user.albumId, displayAlbum); } function displayAlbum(album) { displayPhotoList(album.photos); } httpGet("/users/12345", getUserBlogAndPhoto); [/sourcecode] Wykorzystując zdobytą wiedzę o obietnicach, możemy przepisać ten kod w następujący sposób: [sourcecode language="javascript"] function getUserData() { return httpGet("/users/12345"); } function getBlog(user) { return httpGet("/blogs/" + user.blogId); } function displayBlog(blog) { displayPostList(blog.posts); } function getPhotos(user) { return httpGet("/photos/" + user.albumId); } function displayAlbum(album) { displayPhotoList(album.photos); } function manageError(error) { console.log(error.message); } function getBlogAndPhotos(user) { getBlog(user) .then(displayBlog); getPhotos(user) .then(displayAlbum); } getUserData() .then(getBlogAndPhotos) .catch(manageError); [/sourcecode] W tym przypadku połączyliśmy poprzedni kod oparty na obietnicach z nowym wywołaniem HTTP mającym na celu pobranie zdjęć użytkownika. Do obsługi dwóch asynchronicznych zadań dodaliśmy funkcję getBlogAndPhotos(), która pobiera zarówno wpisy, jak i zdjęcia. Ale w jaki sposób w tym przypadku będą obsługiwane obietnice? Co się stanie, gdy jedna z nich zostanie rozwiązana, a druga odrzucona? Jeśli obie obietnice utworzone w funkcji getBlogAndPhotos() zostaną rozwiązane, na stronie wyświetlą się wpisy i zdjęcia. Nie wiadomo, jaka będzie ich kolejność — będą wyświetlane według porządku otrzymywania i rozwiązywania obietnic. Jednak przeciwnie do tego, czego możesz się spodziewać, jeśli przynajmniej jedna z obietnic zostanie odrzucona, nie zostanie ona obsłużona w planowany przez nas sposób. Funkcja manageError() nie zostanie wywołana. Zgodnie z tym, co napisałem na temat mechanizmu łączenia w łańcuchy wywołań metody then(), jeśli funkcja obsługi rozwiązanej obietnicy nie zwróci niczego, tak jak w naszym przypadku, nastąpi utworzenie nowej rozwiązanej obietnicy, która zostanie przekazana do następnej funkcji obsługowej w łańcuchu. Kiedy więc zostanie wykonana funkcja getBlogAndPhotos(), przekaże ona niejawnie rozwiązaną obietnicę do następnej funkcji obsługowej. Ponieważ na końcu wywołujemy metodę catch(), rozwiązana obietnica zostanie zignorowana i łańcuch obietnic będzie uznany za rozwiązany. Gdy jedna lub obie obietnice utworzone przez funkcje getBlog() i getPhotos() zostaną odrzucone, nie będą już mogły przekazać obiektu do metody catch(), ponieważ nie będzie ona już czekała na ich załatwienie. Gdy potrzebny jest wynik mnogiego zadania asynchronicznego, należy użyć metody all() konstruktora Promise. Metoda ta może poczekać na rozwiązanie wszystkich powiązanych obietnic. Możemy więc zsynchronizować wszystkie asynchroniczne zadania i obsłużyć wyniki ich wszystkich jednocześnie. Metoda Promise.all() przyjmuje tablicę obietnic i tworzy obietnicę, która jest rozwiązana, gdy wszystkie znajdujące się w tablicy obietnice są spełnione. Gdy wszystkie obietnice w tablicy są spełnione, do funkcji obsługowej zostaje przekazana tablica z otrzymanymi wartościami. Jeżeli którakolwiek z obietnic zostanie odrzucona, następuje odrzucenie całej tablicy obietnic i wywołanie metody catch(). W związku z tym dzięki metodzie Promise.all() możemy poczekać z wyświetlaniem treści, aż wszystkie wpisy i zdjęcia staną się dostępne. To z kolei daje nam możliwość określenia kolejności wyświetlania elementów treści i podjęcia odpowiednich działań w przypadku odrzuceń. Nasz poprzedni kod możemy zatem przepisać w następujący sposób: [sourcecode language="javascript"] getUserData() .then(function(user) { var promises = []; var blog = getBlog(user); var album = getAlbum(user); promises.push(blog); promises.push(photos); Promise.all(promises) .then(function(results) { displayBlog(results[0]); displayAlbum(results[1]); }) }) .catch(manageError); [/sourcecode] Zdefiniowaliśmy tablicę promises i wstawiliśmy do niej obietnice utworzone przez wywołania funkcji getBlog() i getAlbum(). Następnie tablicę tę przekazujemy do metody Promise.all(), która utworzy dla nas nową obietnicę. Kiedy obie obietnice zostaną rozwiązane, wyświetlimy dane zwrócone przez serwer w takiej kolejności, w jakiej będziemy chcieli. W przykładzie najpierw prezentujemy wpisy, a potem zdjęcia. Jeśli któraś z obietnic zostanie odrzucona, zgodnie z oczekiwaniami będzie wykonana metoda catch(). Innym możliwym sposobem pracy z wieloma zadaniami asynchronicznymi jest użycie metody Promise.race(). Podobnie jak Promise.all() metoda ta pobiera tablicę obietnic i tworzy nową obietnicę. Różni się od niej tym, że rozwiązuje utworzoną obietnicę, gdy którakolwiek z obietnic znajdujących się w tablicy zostanie rozwiązana. Przy użyciu tej techniki można wyświetlić pierwszą treść, która stanie się dostępna po wysłaniu żądań wpisów i zdjęć do serwera. Poniżej znajduje się jej przykładowa realizacja w postaci kodu źródłowego: [sourcecode language="javascript"] Promise.race(promises) .then(function(result) { if (result.posts) displayBlog(results); if (result.photos) displayAlbum(results); }) .catch(manageError); [/sourcecode] Obietnica związana z tablicą promises zostanie rozwiązana w chwili wpłynięcia wpisów z bloga lub zdjęć. W funkcji obsługującej sprawdzamy, jaka treść dotarła, i wywołujemy odpowiednią funkcję do jej wyświetlenia.  


Tekst pochodzi z książki "Mistrzowski JavaScript. Programowanie zorientowane obiektowo" (Andrea Chiarelli) - Wyd. Helion 2017.Sprawdź książkę >>Sprawdź eBook >>