Skocz do treści

Już wkrótce odpalamy zapisy na drugą edycję next13masters.pl. Zapisz się na listę oczekujących!

Promise: 9 rzeczy, których nie wiesz na temat Promise

Każdy programista wcześniej czy później ściera się z problemem asynchroniczności. Jest to temat bardzo złożony, nawet w języku jednowątkowym jakim jest JavaScript. Promise jest abstrakcją, która stara się asynchroniczność ukryć oraz sprawić, aby korzystanie z niej było dla nas przyjemniejsze i bardziej przewidywalne.

Zdjęcie Michał Miszczyszyn
Dobry Kod18 komentarzy

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:

  1. Oczekujący (pending): Jeszcze nie wiesz czy dostaniesz prezent, czy nie.
  2. Rozwiązany (resolved): Dostałaś/eś prezent.
  3. 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 funkcja Promise.resolve? To przecież flatMap gdy wywołamy ją na innym obiekcie Promise oraz map gdy na wartości niebędącej Promise.

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ętrzny Promise 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:

https://typeofweb.com/map-i-reduce-w-js/

Map i Reduce w JS

Napisałem artykuł o obserwablach, ale czegoś mi w nim zabrakło: Objaśnienia tak podstawowych pojęć i funkcji jak map i reduce. Observable na blogu pojawią się wkrótce, a ten krótki wpis ma na celu tylko lekkie wprowadzenie. Bardzo krótko i pobieżnie.

 

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!

 

👉  Znalazłeś/aś błąd?  👈Edytuj ten wpis na GitHubie!

Autor