Obietnice (promises) – podstawy języka JavaScript

Obietnice - podstawy języka JavaScript

Asynchroniczność

Podstawową informacją, od której chciałbym zacząć, jest jednowątkowość w JavaScript. Jednowątkowość oznacza, że w trakcie działania, program potrafi wykonać tylko jedną czynność na raz według zadanej kolejności. Działa to mniej więcej tak:

fs = require('fs');
console.log(fs.readFileSync('file1.txt', 'utf8'));
console.log(fs.readFileSync('file2.txt', 'utf8'));
console.log(fs.readFileSync('file3.txt', 'utf8'));

W każdym kolejnym kroku, metoda readFileSync() odczytuje z dysku plik, przechodząc od pierwszej do ostatniej linii programu, wypisuje do konsoli zawartości kolejno wczytanych plików.

Co jeśli plik jest wyjątkowo duży? Wtedy oczywiście wczytywanie pliku będzie trwało dłużej, ale dopiero po wczytaniu całej jego zawartości program przejdzie do kolejnej linii kodu.

Dobrze byłoby więc, mieć mechanizm pozwalający na wykonanie pewnych zadań w „tle”, bez blokowania innych zadań. W tym miejscu z pomocą przychodzi nam asynchroniczność. Jeśli chcemy, aby pobranie danych z zewnętrznego API nie blokowało nam działania programu, posłużyć się możemy technologią Ajax, która to asynchronicznie wykona zapytanie do API i zwróci wynik zapytania.

/*
 * Metoda getAsync() jest metodą poglądową, mającą na celu pokazanie, 
 * że zapytanie odbywa się asynchronicznie, najprawdopodobniej wykorzystywała by ona w tym celu technologię Ajax.
 */
var data = getAsync('https://jsonplaceholder.typicode.com/posts/1');
console.log(data);

Jeśli spodziewałeś się, że powyższy kod wypisze dane otrzymane z żądania wysłanego przez metodę getAsync() to niestety muszę Cię zmartwić. Zmienna data nie będzie zawierać żadnych danych. Przebieg programu w wyżej opisanej sytuacji wygląda następująco.

JavaScript tworzy zmienną data. Następnie uruchamiana jest metoda asynchroniczna getAsync() i bez czekania na jej rezultat, program stara się wyświetlić zawartość zmiennej data.

Domyślasz się już zapewne, że w takim razie należy użyć innego podejścia podczas pracy z metodami asynchronicznymi. Tak jest! Sposobów na pracę z kodem asynchronicznym jest kilka. Zacznijmy zatem od klasycznego podejścia z użyciem wywołań zwrotnych (ang. callback).

Callback i piekło wywołań zwrotnych

Funkcja callback to funkcja przekazana jako parametr innej funkcji. Dzięki takiemu podejściu, możliwe jest wskazanie działania, które ma nastąpić, w zależności od wyników działania poprzedniej funkcji.

getAsync('https://jsonplaceholder.typicode.com/posts/1', function callback(data) { 
    console.log(data); 
});

W powyższym przykładzie metoda getAsync(), wyśle zapytanie pod wskazany adres URL, a odpowiedź zostanie przekierowana do funkcji callback(). W ten sposób, dopiero gdy nadejdzie odpowiedź z zewnętrznego API, dane zostaną wyświetlone w konsoli.

No dobrze, to skoro to działa, to dlaczego potrzebujemy innych sposobów na pracę z asynchronicznością?

Bardzo często, aby sprostać wymaganiom biznesowym, będziemy potrzebowali wielokrotnego zagnieżdżania wywołań zwrotnych. Powstały w ten sposób kod jest trudny w utrzymaniu i mało czytelny. Często zdarza się też, że kolejne funkcje zwrotne porozrzucane są w różnych miejscach kodu czy nawet w różnych plikach, co dodatkowo utrudnia pracę. Tego typu sytuację nazywamy piekłem wywołań zwrotnych (ang. callback hell). callback hell Jeśli mowa o callback hell, to nie może zabraknąć Ryu ze Street Fighter’a i jego Hadouken’a (żródło: http://nintendo.wikia.com/wiki/Hadouken).

Obietnice

Czas więc na tytułowe obietnice (ang. promises). Obietnice powstały z myślą o usprawnieniu pracy z kodem asynchronicznym. Ich podstawowymi zaletami są: lepsza kontrola w zakresie synchronizacji wywołań (brak wyścigów), obsługa błędów oraz lepsza czytelność.

Zobaczmy zatem przykładową implementację obietnic w JavaScript, gdzie metoda getAsync() tworzy obietnicę new Promise(), że zrealizuje asynchroniczne połączenie z podanym adresem URL.

function getAsync(url) { 
    return new Promise((resolve, reject) => { 
        var httpReq = new XMLHttpRequest(); 
        httpReq.onreadystatechange = () => { 
            if (httpReq.readyState === 4) { 
                if (httpReq.status === 200) { 
                    resolve(JSON.parse(httpReq.responseText)); 
                } else { 
                   reject(new Error(httpReq.statusText)); 
                } 
            } 
        }
        httpReq.open("GET", url, true); 
        httpReq.send(); 
    }); 
}

getAsync('https://jsonplaceholder.typicode.com/posts/1')
    .then((data) => { 
        const post = 'Title: ' + data.title + '\n\nBody: ' + data.body; alert(post) 
    }).catch((err) => { alert(err); }
);

https://jsfiddle.net/has49xj8/

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

  • spełniona/rozwiązana (fulfilled/resolved) – gdy powiązane z nią zadanie zwraca żądaną wartość,
  • odrzucona (rejected) – gdy zadanie nie zwraca wartości, np. z powodu wyjątku lub zwrócona wartość jest nieprawidłowa,
  • oczekująca (pending) – to stan od rozpoczęcia żądania do otrzymania wyniku.

W naszym przypadku, gdy httpReq.readyState() będzie mieć wartość 4 oraz httpReq.status() równa się 200, wtedy obietnica zostaje spełniona, a do funkcji resolve() zostaje przekazana sparsowana odpowiedź z API. Gdyby jednak status miał inną wartość, wtedy obietnica znajdzie się w stanie odrzuconym reject() do którego przekazujemy stosowną informację.

Opierając się na powyższym przykładzie, może być trudno dostrzec korzyści płynące z użycia obietnic zamiast standardowych wywołań zwrotnych. Zazwyczaj jednak obietnic będziemy używać w kontekście tworzenia łańcuchów wywołań, gdzie jedna obietnica będzie konsumować kolejną. Przyjrzyjmy się zatem bardziej rozbudowanemu przykładowi.

W poniższym przykładzie upieczemy ciasto. Aby tego dokonać będziemy potrzebować trzech kroków: 1. zrobić zakupy, 2. zmieszać składniki, 3. upiec ciasto.

let doShopping = () => {return new Promise((resolve, reject) => resolve ('Got ingredients'))}
let mixIngredients = () => {return new Promise((resolve, reject) => resolve ('Made dough'))}
let bakeCake = () => {return new Promise((resolve, reject) => resolve ('Baked cake'))}

doShopping()
.then((response) => { alert(response); return mixIngredients() })
.then((response) => { alert(response); return bakeCake() })
.then((response) => { alert(response); alert('Bon appetit') });

https://jsfiddle.net/0xrffx6a/

Jak widać na przykładzie, obietnice możemy łączyć w sekwencję wywołań. Jest to możliwe dzięki temu, że każde wywołanie then() zwraca nam nową obietnicę z ustawioną wartością zwrotną przekazaną do metody resolve(). W wyniku działania powyższego skryptu otrzymamy serię wywołań metody alert() z przekazanymi do niej wartościami pochodzącymi ze spełnienia obietnic, czyli z metod resolve().

Przechwycenie błędów

W pierwszym przykładzie dotyczącym obietnic, jawnie zdefiniowaliśmy w jakim momencie obietnica powinna zostać odrzucona (poprzez użycie metody reject()). Należy jednak wiedzieć, że odrzucenie obietnicy może nastąpić także w wyniku wystąpienia błędu. Dzięki sekwencji wywołań, informacja o błędzie jest przenoszona pomiędzy wywołaniami then(). Dodajmy zatem do naszego łańcucha wywołań kod odpowiedzialny za przechwycenie błędów.

doShopping()
.then((response) => { alert(response); return mixIngredients() })
.then((response) => { alert(response); return bakeCake() })
.then((response) => { alert(response); alert('Bon appetit') })
.catch((error) => {alert(error.message)});

Pomimo, iż żadna z metod jawnie nie wywołuje reject() to jeśli gdzieś na etapie wykonywania zadań JavaScript zwróci błąd, zostanie on przechwycony i obsłużony w metodzie catch().

Promise.all()

Jeśli chcemy obsłużyć więcej niż jedną obietnicę naraz, a nie zależy nam na kolejności w jakiej obietnice te zostaną rozwiązane, wtedy możemy posłużyć się metodą Promise.all([…]). Jako parametr przyjmuje ona w tablicy, egzemplarze obietnic. Jeśli wszystkie obietnice zostaną spełnione, Promise.all() zwraca obiekt Promise wraz z tablicą wartości zwróconych ze wszystkich spełnionych obietnic. Kolejność wartości zwróconych w tablicy jest taka sama jak kolejność obiektów Promise przekazanych do Promise.all().

Przyjrzyjmy się przykładowi poniżej.

const promise1 = new Promise(function(resolve, reject) {
  setTimeout(resolve, 2000, 'foo');
});
const promise2 = 42;
const promise3 = Promise.resolve(2*21);

Promise.all([promise1, promise2, promise3]).then(function(values) {
  alert(values);
}).catch((err) => {console.log(err)});

https://jsfiddle.net/ptqk1ncg/

Do metody Promise.all() trafiają dwa obiekty Promise i literał liczbowy równy 42. Bez znaczenia, który z tych obiektów zostanie rozwiązany jako pierwszy, rezultatem wykonania będzie metoda alert() z tablicą o zawartości [’foo’,42,42].

Jeżeli którakolwiek z obietnic zostanie odrzucona, to Promise.all() również zostanie odrzucone (tracąc wszystkie wartości, które udało się jej uzyskać w wyniku rozwiązania pozostałych obietnic). Wartością zwróconą będzie wtedy wartość z odrzucenia pierwszej z obietnic.

const promise1 = new Promise(function(resolve, reject) {
  setTimeout(reject, 1500, 'error1');
  setTimeout(resolve, 2000, 'foo');
});
const promise2 = new Promise(function(resolve, reject) {
  setTimeout(reject, 1000, 'error2');
});
const promise3 = Promise.resolve(2*21);

Promise.all([promise1, promise2, promise3]).then(function(values) {
  alert(values);
}).catch((err) => {console.log(err)});

https://jsfiddle.net/8zjocfLg/

Wynikiem działania powyższego skryptu będzie wypisanie do konsoli słowa 'error2′ ponieważ jest to najwcześniej odrzucona obietnica.

Promise.race()

W odróżnieniu od metody Promise.all(), która czekała do momentu rozwiązania wszystkich obietnic, metoda Promise.race() zwróci pierwszą zakończoną obietnicę.

const promise1 = new Promise((resolve, reject) => {
    setTimeout(resolve, 5000, 'first');
});

const promise2 = new Promise((resolve, reject) => {
    setTimeout(resolve, 1000, 'second');
});

Promise.race([promise1, promise2]).then(function(value) {
  console.log(value);
});

https://jsfiddle.net/d1trvvtk/

W przykładzie powyżej, obie obietnice zostaną rozwiązanie, ale ponieważ pierwszą rozwiązaną będzie promise2, tak więc wartość wypisana do konsoli to ’second’.

Podsumowanie

Obietnice w JavaScript to żadna nowość, pojawiły się one w czerwcu 2015 roku w specyfikacji ES6, ale były znane już wcześniej we frameworkach JavaScriptowych, na przykład Deferred() z biblioteki Jquery. Obietnice wprowadzono, aby ułatwić pracę z kodem asynchronicznym, zapobiec tworzeniu piekła wywołań zwrotnych oraz uczynić kod bardziej czytelnym i łatwiejszym w utrzymaniu.

Ale i w przypadku użycia obietnic należy być czujnym. Niewłaściwa ich implementacja może spowodować te same problemy co wywołania zwrotne, w efekcie których skończymy w piekle obietnic (ang. promise hell).

getData().
    .then((post) => {
        return getComments(post)
            .then((comments) => {
                return getUser(post)
                    .then((user) => {
                        return commentAccess(user, post, comments);
                    });
            });
    });

Obietnice są wspierane we wszystkich nowoczesnych przeglądarkach internetowych. Chociaż na uwagę zasługuje brak wsparcia ze strony „starego” Internet Explorera, więc wszędzie tam, gdzie wymagania klienta mówią o dostępności oprogramowania na systemy z zainstalowanym IE trzeba się wstrzymać od używania obietnic lub użyć biblioteki, która takie obietnice nam zasymuluje np. es6-promise. W znacznie większej jednak ilości przypadków powinniśmy się zastanowić czy aby na pewno implementacja wywołań zwrotnych ma sens i spróbować w ich miejsce użyć obietnic.

Warto zobaczyć

  1. Ciekawym narzędziem do nauki obietnic jest promise-it-wont-hurt, poszerzy ono zdobytą wiedzę i pozwoli na praktyczne jej zastosowanie.
  2. Oficjalna dokumentacja dostępna jest na stronie developer.mozilla.org.
  3. Specyfikację techniczną można znaleźć na Promises/A+.
Z zawodu i zamiłowania programista. Jara mnie wszystko, co potrafi zmienić nieciekawy input w ciekawy output, czyli m.in. programowanie, gotowanie, muzyka i fotografia.
PODZIEL SIĘ

2 KOMENTARZE

  1. Następne są async/await. Nadbudówki na obietnice, które jeszcze bardziej uproszczają kod.

  2. […] Obietnice w JavaScript powstały, by ułatwić pracę z kodem asynchronicznym, który często jest stosowany ze względu na jednowątkowość w JavaScript. Jeśli zatem jesteście na etapie nauki obietnic, polecam ciekawe narzędzie Promise visualization playground for the adventurous, które ułatwi zrozumienie zasad ich działania, a także pozwoli zobrazować kod obietnic. Jeśli znacie inne ciekawe narzędzia do nauki tej technologii, podzielcie się proszę tą informacją w komentarzach do tego posta. […]

Comments are closed.