Podstawy działania Promise nie są trudne, jednak wiele osób ma problemy ze zrozumieniem ich na samym początku i z załapaniem podstawowych idei. W tym wpisie przedstawiam kilka faktów i ciekawostek, które wpłynęły na sposób w jaki postrzegam Promise. Mam nadzieję, że pomogą one Tobie lepiej zrozumieć mechanizmy jego działania :)
Ten wpis mówi konkretnie o Promise A+. Zajrzyj do specyfikacji!
1. Promise to obietnica
Promise to po polsku „obietnica”. To słowo naprawdę doskonale oddaje ideę działania tej abstrakcji! Wyobraź sobie, że ktoś obiecuje Ci, że dostaniesz prezent. Nie wiesz kiedy to nastąpi. Nie wiesz nawet czy na pewno to nastąpi. Ale niezależnie od tego – ostatecznie kiedyś dowiesz się czy obietnica została dotrzymana, czy też nie. Tak dokładnie działa Promise.
Promise może być w jednym z 3 stanów:
- Oczekujący (pending): Jeszcze nie wiesz czy dostaniesz prezent, czy nie.
- Rozwiązany (resolved): Dostałaś/eś prezent.
- Odrzucony (rejected): Niestety prezentu nie będzie :(
Zamieńmy to na kod!
Promise w JavaScript
const promisedPresent = getPresent();
promisedPresent
.then(present => console.log('Super prezent!', present))
.catch(error => console.log('Nie ma prezentu :(', error));
Funkcja przekazana do then
wykona się tylko jeśli obietnica zostanie spełniona, a ta przekazana do catch
jeśli nie. Co istotne, nie wiesz dokładnie kiedy to się wydarzy. Może za sekundę, może za rok :)
Wejdźmy głębiej w funkcję getPresent
– tworzymy obietnicę w ten sposób:
function getPresent() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('Oto prezent!');
}, 5000); // 5 sekund
});
}
Tutaj po upływie 5 sekund otrzymujesz prezent. Najs. Ale to już na pewno wiesz, prawda? Przejdźmy więc do ciekawostek ;)
2. Promise'y można łączyć
Składnia jest łatwa i przyjemna, więc teraz wyobraźmy sobie, że chcemy wykonać kilka zadań asynchronicznych jedno po drugim. Moglibyśmy się pokusić o napisanie takiego kodu:
getPresent()
.then(present => {
return returnToTheShop(present)
.then(returnedMoney => {
return buyNewiPhone(returnedMoney)
.then(iPhone => iPhone.openTypeOfWeb())
});
});
Działa! Jednak poziom zagnieżdżenia sprawił, że kod jest całkowicie nieczytelny! Ale na szczęście Promise mają pewne właściwości, które możemy tutaj wykorzystać. Ten sam kod, czytelniej, można zapisać bez zagnieżdżeń:
getPresent()
.then(present => returnToTheShop(present))
.then(returnedMoney => buyNewiPhone(returnedMoney))
.then(iPhone => iPhone.openTypeOfWeb());
lub nawet lepiej:
getPresent()
.then(returnToTheShop)
.then(buyNewiPhone)
.then(iPhone => iPhone.openTypeOfWeb());
Ale to pewnie też dla Ciebie powtórka informacji. Zastanówmy się więc nad trudniejszymi aspektami.
3. Callbacki do Promise'a można przekazać dużo później
To często jest zaskoczeniem dla osób, które nigdy nie używały Promise. W momencie tworzenia obietnicy nie trzeba do niej podpinać jeszcze żadnych funkcji. Ba, funkcje te można podpiąć znacznie później, niekoniecznie jedną – może ich być nawet kilka.
const myPromise = new Promise((resolve, reject) => {
setTimeout(() => resolve('Gotowe!'), 5000);
});
setTimeout(() => {
myPromise.then(val => console.log(val));
}, 6000);
Widzimy tutaj, że callback jest podpinany po 6 sekundach, a Promise rozwiązuje się po 5 sekundach. Czyli callback zostaje podpięty dopiero sekundę później. I mimo to działa :) Koncepcja jest prosta: Promise najpierw jest oczekujący, a później rozwiązany. Wszystkie podpięte callbacki zostaną wywołane z rozwiązaną wartością jak tylko będzie to możliwe – niezależnie czy były podłączone wcześniej, czy później.
4. then
to jednocześnie map
i flatMap
Tutaj kończy się łagodne wprowadzenie. W innym moim wpisie mogliście przeczytać takie zdanie:
Czym na przykład jest funkcjaPromise.resolve
? To przecieżflatMap
gdy wywołamy ją na innym obiekciePromise
orazmap
gdy na wartości niebędącejPromise
.
Co to tak naprawdę oznacza? Zastanów się co możesz zwrócić wewnątrz funkcji then
, resolve
i catch
:
Promise
dowolnej wartości (a właściwie to dowolny obiekt spełniający definicję "thenable" – o tym wspomnę dalej!)- dowolną wartość
Czemu rozdzielam to na dwie kategorie? Dlatego, że działanie Promise'ów zmienia się i jest inne gdy przekazujemy coś z pierwszej kategorii i inne gdy coś z drugiej:
- gdy wewnątrz zwracasz
Promise
, to ten zewnętrznyPromise
poczeka na ten w środku - gdy zwracasz inną wartość to po prostu zostanie ona przekazana dalej
Brzmi skomplikowanie? Na przykładzie okazuje się, że jest to bardzo intuicyjne. Zacznijmy od puntu drugiego czyli dowolnej wartości niebędącej Promise
:
Promise
.resolve(1)
.then(() => 2) // zamiast 1 zwracamy 2 i od razu jest ono przekazane dalej
.then(val => console.log(val)) // wyświetla 2
To wydaje się oczywiste, prawda? Zwracamy 2, więc w kolejnym then
dostajemy wartość 2. Inaczej funkcja zachowa się gdy zwrócimy obietnicę:
const promiseWithThree = new Promise((resolve, reject) => {
setTimeout(() => resolve(3), 5000); // po 5 sekundach Promise zostanie rozwiązany z wartością 3
});
Promise
.resolve(1)
.then(() => promiseWithThree) // zamiast 1 zwracamy promise, który po 5 sekundach rozwiąże się z 3
.then(val => console.log(val)) // po rozwiązaniu `promiseWithThree` wyświetla 3
Tutaj zwracamy promiseWithThree
, a wtedy zewnętrzny Promise
czeka na niego i dopiero wtedy wykonuje callbacki przekazane do kolejnych then
. Tak jak mówiłem, jest to bardzo intuicyjne, prawda? Jednak nie jest to wcale oczywiste! Wewnątrz drugiego then
, val
nie jest Promisem tylko wartością z którą rozwiązał się tamten Promise
.
Dlatego właśnie często mówi się, że resolve
czy then
to jednocześnie map
i flatMap
w nomenklaturze Haskellowej. Aby trochę lepiej poznać te pojęcia polecam mój inny wpis:
5. Promise jest asynchronicznym odpowiednikiem synchronicznych wywołań
Gdyby jedyną fajną rzeczą w Promisach była… agregacja callbacków – nie byłoby w ogóle tego wpisu :) Obietnice tak naprawdę to znacznie bardziej skomplikowany i rozbudowany koncept. Obietnica jest bezpośrednim asynchronicznym odpowiednikiem dla zwykłych wywołań synchronicznych. Co robią zwykłe synchroniczne funkcje? Zwracają wartość lub rzucają wyjątek. Dokładnie to samo robią Promise'y.
Niestety w asynchronicznym świecie nie można po prostu zwrócić wartości albo złapać błędu – stąd cała abstrakcja. Jednak pozostałe koncepty i zachowania są niemal identyczne! Jeśli zagnieździmy kilka synchronicznych funkcji, a któraś z nich rzuci wyjątek – to ten wyjątek przerwie pozostałe wywołania i powędruje do góry aż zostanie złapany. Dokładnie to samo robią Promise'y. W momencie w którym zdasz sobie z tego sprawę – jesteś już bardzo blisko dogłębnego zrozumienia obietnic. Wyjątek rzucony w którejś z zagnieżdżonych obietnic spowoduje przerwanie wywołań kolejnych then
i powędruje do najbliższego catch
, który ten błąd obsłuży. Widzisz tutaj podobieństwo? Spójrz na przykład:
Promise
.resolve(1)
.then(val => {
console.log(val);
return val + 1;
})
.then(val => promiseWithError)
.then(val => console.log(val)) // to się nie wykona gdyż błąd w `promiseWithError` przerywa ciąg wywołań
.catch(error => console.error(error)) // od razu trafiamy tutaj
6. Promise to mechanizm aplikowania transformacji
Wow, to brzmi tajemniczo i skomplikowanie, prawda? Postaram się wytłumaczyć o co w tym chodzi. then
nie jest tylko sposobem na podpisanie kolejnych callbacków do obietnicy. Jest to tak naprawdę mechanizm aplikowania transformacji, który zwraca nowy Promise po każdej transformacji. Spójrzmy na szybki przykład:
const p1 = Promise.resolve(1) // zwraca 1
const p2 = p1.then(val => val + 1) // zwraca 2
const p3 = p1.then(val => val + 1) // zwraca 2
const p4 = p2.then(val => val + 1) // zwraca 3
Co istotne – każdy z powstałych obiektów, mimo że bazuje na wartości z p1
, nie modyfikuje nigdy p1. Mówiąc krótko, p1 != p2 != p3 != p4
.
To jeden z powodów, dla których implementacja Promise w jQuery była szeroko krytykowana. Mechanizm transformacji oraz zwracanie nowego Promise'a nie były tam prawidłowo zaimplementowane – zamiast tego mutowany był stan istniejącej obietnicy, co było niezgodne ze standardem Promise/A+!
Możliwe transformacje
Promise, poza oczekującym, może mieć dwa stany: rozwiązany lub odrzucony, odpowiednio zostaje wtedy wywołana funkcja then
lub catch
. Wewnątrz każdej z tych funkcji mogą się wydarzyć dwie rzeczy: Zwrócona jest wartość lub zostaje rzucony wyjątek. Łącznie mamy 4 kombinacje. Zwróć uwagę, że wyjątek rzucony wewnątrz then
sprawi, że Promise zostanie odrzucony i zostanie wywołany najbliższy catch
. To bardzo ważne!
Promise
.resolve(1)
.then(val => {
return Promise
.resolve(val + 1)
.then(newVal => {
doSth(newVal) // funkcja nie istnieje, wyjatek!
})
})
.catch(err => {
// tutaj zostaną złapane wszelkie błędy, które nie zostały złapane wcześniej
// zarówno synchroniczne (throw) jak i asynchroniczne (Promise.reject itp.)
console.log(err);
})
Celowo zagnieździłem te wywołania w sobie, aby pokazać, że – o ile po drodze nie było innego catch
– wszystkie błędy zostaną złapane w catch
na najwyższym poziomie.
7. Promise współpracuje z dowolnym „thenable”
To będzie krótki akapit. Wspomniałem chwilę wcześniej, że Promise potraktuje jak obietnicę dowolny obiekt typu „thenable”. Jak wygląda taki obiekt? Spójrz na przykład:
const thenable = {
then(onResolved, onRejected) {
onResolved(1);
}
};
Promise
.resolve(thenable)
.then(val => console.log(val)); // 1
Thenable to po prostu zwykły obiekt, który ma funkcję then
. Jakie są tego konsekwencje? Przede wszystkim: Bardzo łatwo zamienić dowolny obietnico-podobny obiekt na prawdziwy Promise/A+! Przykładowo, Promise z jQuery zamieniamy na prawdziwy Promise wywołując na nim po prostu Promise.resolve(…)
. Bum!
8. Obsługa błędów
catch
umożliwia nam złapanie wszystkich odrzuceń i wyjątków – to świetnie! Pozwala to na przykład na globalne przechwytywanie nieobsłużonych błędów w jednym centralnym miejscu. Przykładowo, robi tak framework HapiJS: Oczekuje, że funkcja handler
zwróci Promise
– jeśli jest on rozwiązany, to Hapi automatycznie wysyła odpowiedź z odpowiednim kodem 20x, a jeśli odrzucony to Hapi przechwytuje ten błąd i zamienia na odpowiedź z kodem błędu 500, lub innym podanym. To bardzo wygodne! Ale co to dokładnie oznacza, że błąd jest nieobsłużony?
Napisałem akapit wcześniej, że wewnątrz catch
również może zostać zwrócona wartość. Jeśli zwrócisz inny odrzucony Promise albo rzucisz wyjątek, to catch
zwróci kolejny odrzucony Promise
. Ale jeśli zwrócisz dowolną inną wartość, to catch
zwróci Promise
, który nie jest odrzucony. Oznacza to, że błąd został przez Ciebie obsłużony. Spójrz na przykłady:
Promise
.reject(new Error())
.catch(err => {
console.log(err);
return 'jest ok' // obsluguję blad
})
.then(val => console.log(val)) // 'jest ok'
Tutaj błąd jest obsłużony, więc następnie wywoła się then
.
Promise
.reject(new Error())
.catch(err => {
console.log(err);
return Promise.reject('jest ok') // nie obsługuję błędu
})
.then(val => console.log(val)) // nie wywoła się
.catch(err => console.log('tutaj jestem!')) // wywoła się, bo błąd nie był obsłuzony poprzednio
Natomiast tutaj błąd nie jest obsłużony, więc then
nie wywoła się. Zostanie wywołany natomiast kolejny najbliższy catch
.
Bardzo ważne jest, aby zawsze zwracać coś wewnątrz then
i catch
. Najlepiej niech wejdzie Ci to w nawyk. Jeśli nie zwrócisz nic wewnątrz catch
to automatycznie zwrócone zostaje undefined
, a to oznacza, że błąd został obsłużony:
Promise
.reject(new Error())
.catch(err => {
console.log(err); // ups! przypadkiem nic nie zwróciłem, błąd obsłuzony
})
.then(val => console.log(val)) // wywoła się!
Ostatni then
wywoła się, gdyż wewnątrz catch
przypadkiem niczego nie zwróciłem – czyli został zwrócony undefined
i błąd został „obsłużony”.
Podsumowanie
Przedstawiłem kilka podstawowych informacji, nieco ciekawostek i kilka całkiem zaawansowanych rzeczy związanych z Promise. Niektóre mnie zaskoczyły gdy się o nich kiedyś dowiedziałem. A czy Ciebie coś zaskoczyło? Napisz w komentarzu!