Frontend

Wywołanie zwrotne, obietnice i składnia async/await w JavaScript

Jedną z najbardziej istotnych i jednocześnie powszechnie wykorzystywanych możliwości języka JavaScript jest wykonywanie operacji asynchronicznych. JavaScript udostępnia zestaw stale rozwijanych technik służących do zarządzania asynchronicznością. W niniejszym wpisie przybliżę sposób użycia trzech z nich: funkcje wywołania zwrotnego, obietnice oraz operacje z zastosowaniem składni async/await.

Łukasz Amróz. Front-End Developer z 3 letnim doświadczeniem komercyjnym. Zawodowo związany z warszawską firmą TechGarden, gdzie jest odpowiedzialny za rozwój warstwy front-endowej systemu wypożyczalni samochodów elektrycznych Vozilla. Przygodę z programowaniem rozpoczynał od współpracy z CraftWare. Pasjonat technologii React.js i Vue.js, z którymi ma przyjemność pracować na co dzień. Prywatnie fan siatkówki i Formuły 1, miłośnik polskich gór, wielbiciel twórczości Fryderyka Chopina.


Asynchroniczność – co oznacza?

JavaScript to jednowątkowy język programowania. Używając prostych słów, oznacza to, że wykonanie każdego wyrażenia zapisanego w skrypcie zostanie rozpoczęte w momencie zakończenia wykonania poprzedniego. Posiadając wiedzę na temat charakterystyki współczesnych aplikacji (typu Single Page Application) nastawionych na ciągłą komunikację z serwerem w celu wymiany danych, nie ciężko wyobrazić nam sobie sytuację, w której wykonanie skryptu zostaje zablokowane np. za sprawą czasochłonnej operacji pobrania kilku tysięcy rekordów danych, przeznaczonych do wyświetlenia użytkownikowi aplikacji lub wygenerowania raportu w formie pliku PDF.

Uniknięcie zablokowania wykonania skryptu możemy osiągnąć za pomocą asynchroniczności, która pozwala rozpocząć wykonanie pewnej operacji w danym momencie i obsłużenie jej rezultatu w przyszłości. W międzyczasie skrypt nadal będzie mógł być wykonywany, a użytkownik nie doświadczy efektu zawieszenia działania aplikacji.

Funkcje wywołania zwrotnego

Powszechnie wykorzystywane w środowisku Node JS, funkcje wywołania zwrotnego (ang. callback) są najstarszą udostępnianą przez JavaScript techniką zarządzania asynchronicznością. W kontekście asynchroniczności callback jest niczym innym jak funkcją, która zostanie wywołana w momencie, kiedy wykonanie asynchronicznego kodu zostanie zakończone. Spójrzmy na poniższy przykład.

function getUser (callback) {
  setTimeout(() => {
    callback({firstName: ‘John’, lastName: ‘Doe’});
  }, 2000);
};
 
getUser(user => console.log(user));

Funkcja getUser przyjmuje jeden argument – funkcję wywołania zwrotnego i korzystając z udostępnianej przez przeglądarki metody setTimeout, po upływie dwóch sekund wywołuje przekazaną wcześniej funkcję, wraz z odpowiednim zestawem argumentów – w naszym wypadku danymi użytkownika, które następnie są wyświetlane w konsoli deweloperskiej.

Callbacki doskonale sprawdzają się przy obsłudze prostych operacji asynchronicznych, nie są jednak rozwiązaniem łatwo skalowalnym, co jest szczególnie widoczne w sytuacji gdy musimy wykonać kilka operacji asynchronicznych w odpowiedniej kolejności, a wykonanie kolejnej operacji jest uzależnione od powodzenia poprzedniej.

const verifyUser = (username, password, callback) => {
  getUserData(username, passowrd, (error, userData) => {
    if (error) {
      callback(error);
    } else {
      getUserRole(userData, (error, userRole) => {
        if (error) {
          callback(error);
        } else {
          getUserPermissions(userRole, (error, userPermissions) => {
            if (error) {
              callback(error);
            } else {
              callback(null, userPermissions);
            }
          });
        }
      });
    }
  });
}

W powyższym przykładzie próbujemy przeprowadzić weryfikację użytkownika, uzyskać jego rolę oraz uprawnienia za pomocą sztucznego zestawu metod, operujących na funkcjach wywołania zwrotnego. Każda metoda przyjmuje odpowiedni zestaw argumentów i w zależności od powodzenia wykonania zwraca wartość lub błąd, które są obsługiwane za pomocą funkcji wywołania zwrotnego.

Jak możemy zaobserwować powyżej, stosunkowo prosty proces weryfikacji użytkownika z wykorzystaniem funkcji wywołania zwrotnego dość łatwo staje się mało czytelny tworząc blok kodu nazywany popularnie callback hell. Posiadanie wielu podobnych bloków kodu sprawia, że aplikacja staje się trudna w utrzymaniu, a jednocześnie jesteśmy zmuszeni wielokrotnie powtarzać podobny kod służący np. do obsługi błędów. Uniknięcie uwidocznionego problemu jest możliwe za pomocą innej funkcji udostępnianej przez JavaScript – obietnic.

Obietnice

Standaryzacja obietnic (ang. promise) w języku JavaScript nastąpiła za sprawą specyfikacji ECMAScript 6. Wcześniej ich różne implementacje były dostępne przez wiele lat dzięki takim bibliotekom jak Bluebird czy popularne q, które stało się podstawą do stworzenia jednego z serwisów frameworku AngularJS o takiej samej nazwie.

Obietnica to obiekt, który reprezentuje rezultat lub błąd rozpoczętej operacji asynchronicznej, który będzie dostępny w przyszłości – w momencie zakończenia wspomnianej operacji. Innymi słowy mówiąc obietnica posiada pewną wartość, która będzie dostępna w przyszłości.

Zgodnie ze specyfikacją ECMAScript, każdy obiekt typu promise, znajduje się w jednym z trzech wzajemnie wykluczających się stanów:

  • pending – stan początkowy, wskazujący na to, że operacja jest w trakcie wykonania i oczekujemy na jej rezultat.
  • fulfilled – stan reprezentujący powodzenie wykonywanej operacji.
  • rejected – stan reprezentujący brak powodzenie wykonywanej operacji.

Każdorazowo kiedy obietnica znajdzie się w stanie fulfilled lub rejected, należy ją traktować jako obiekt niezmienny – czyli taki, który nigdy nie zmieni swojej wartości.

Tworzenie obietnic

W większości przypadków mamy do czynienia z gotowymi obietnicami, nadal jednak istotnym jest, aby wiedzieć jak wygląda składnia odpowiadająca za ich tworzenie. Obietnice tworzone są za pomocą konstruktora Promise, który przyjmuje pojedynczy argument – callback, znany również jako funkcja wykonawcza, która to z kolei przyjmuje dwie metody – resolve oraz reject.

Funkcja wykonawcza wywoływana jest natychmiastowo po utworzeniu nowego obiektu, który to w przyszłości może zostać rozwiązany (przeniesiony w stan fulfilled) za sprawą wywołania metody resolve, lub odrzucony (przeniesiony w stan rejected) w wyniku wywołania metody reject. Metody resolve oraz reject mogą przyjąć jeden argument, którym może być łańcuch znaków, liczba, wartość typu Boolean, tablica lub obiekt.

const promise = new Promise((resolve, reject) => {
  const randomNumber = Math.random();
  setTimeout(() => {
    if(randomNumber < 0.5) {
      resolve('All things went well!');
    } else {
      reject('Something went wrong');
    }
  }, 2000);
});

Za pomocą konstruktora Promise tworzymy nową obietnicę, która w tym momencie znajduję się w stanie pending, a jej wartością początkową jest undefined. Po upływie dwóch sekund obietnica znajdzie się w stanie fulfilled, jeżeli wartość zmiennej randomNumber będzie mniejsza niż 0.5, w przeciwnym wypadku zostanie oznaczona jako rejected. Aby tak przygotowana obietnica w pełni spełniała swoje zadanie wymagana jest jej odpowiednia obsługa.

Obsługa obietnic

Proces obsługi obietnic odbywa się za pomocą trzech metod – then, catch oraz finally – dostępnych na obiekcie typu promise.

Metoda then służy zarówno do obsługi operacji zakończonych powodzeniem jak i błędów. Metoda ta pozwala przekazać dwa callbacki, które jako argument przyjmują odpowiednio wartość przekazaną do metod resolve oraz reject. Pierwszy z callbacków wywołany zostanie w momencie, kiedy operacja asynchroniczna zakończona zostanie powodzeniem, drugi natomiast w przypadku wystąpienia błędu.

Metoda catch służy do zarządzania błędami, pojawiającymi się w momencie kiedy promise jest odrzucany i przechodzi w stan rejected. Catch pozwala przekazać callback, który jako argument przyjmuje wartość przekazaną do metody reject. Metoda catch udostępnia składnię, która jest czytelniejsza w stosunku do zarządzania błędami za pomocą metody then.

Metoda finally pozwala przekazać callback, który zostanie wywołany jednokrotnie bez względu na to czy obietnica zostanie rozwiązana czy odrzucona. Metoda finally jest zazwyczaj używana do wykonania czynności mających na celu oczyszczenie logiki np. ukrycie komponentu wskazującego ładowanie zasobów. Użycie finally zapobiega powielaniu wspólnej logiki dla stanów fulfilled i rejected.

promise
  .then(response => console.log(response))
  .catch(error => console.log(error))
  .finally(() => console.log('Some cleanup logic'));

W powyższym przykładzie wykorzystujemy stworzoną przez nas wcześniej obietnicę, do której dodajemy obsługę korzystając z metod then, catch oraz finally. W zależności od wartości zmiennej randomNumber, w konsoli deweloperskiej zobaczymy jeden z dwóch logów ‘All thing went well!’ lub ‘Something went wrong’, a następnie pochodzący z metody finally log ‘Some cleanup logic’.

Powyższy przykład pokazuje jak czytelna i intuicyjna jest obsługa operacji asynchronicznych z wykorzystaniem obietnic. Jednak rzeczywista wartość obietnic ukazuje się w sytuacji gdy mamy do wykonania pewną sekwencję operacji asynchronicznych. Wartość ta wynika z faktu, że metody then oraz catch mogą zwracać kolejne obietnice, przez co jesteśmy w stanie wykonać całe sekwencje operacji asynchronicznych w przejrzysty sposób, unikając tym samym opisanego wcześniej zjawiska callback hell. Spróbujmy zaimplementować wcześniejszy przykład, jednak tym razem wykorzystując obietnice zamiast callbacków.

getUserData(passowrd, username) 
  .then(userData => getUserRole(userData))
  .then(userRole => getUserPermissions(userRole))
  .then(userPermissions => console.log(userPermissions))
  .catch(erorr => console.error(error));

W powyższym przykładzie wykorzystujemy zestaw trzech sztucznych metod, które tym razem zwracają obietnicę. Taka implementacja oraz wykorzystanie składni then, pozwala połączyć wywołanie kolejnych metod i przekazać im odpowiednie argumenty, jednocześnie gwarantując obsługę ewentualnych błędów.

Już pierwsze spojrzenie na powyższy fragment kodu pozwala nam określić w jaki sposób przebiega proces weryfikacji użytkownika. Co więcej udało nam się wyeliminować powtarzanie tej samej logiki dla obsługi błędów, ponieważ wszystkie błędy – niezależnie od momentu, w którym wystąpią – zostaną obsłużone za pomocą jednej metody catch.

Obietnice pozwalają na czytelne i kompleksowe zarządzanie asynchronicznością, nie wyczerpują jednak dostępnych opcji, spójrzmy jeszcze na wykorzystanie składni async/await.

Async/Await

Słowa kluczowe async/await zostały dodane do języka JavaScript za sprawą specyfikacji ECMAScript 8 i stanowią tak zwany syntactic sugar, co oznacza, że ich pojawienie się nie jest związane z dodaniem nowej funkcjonalność języka, a jedynie stanowi wyższy poziom abstrakcji dla zastosowania mechanizmu obietnic. Async/await czynią operacje asynchroniczne łatwiejszymi w zrozumieniu, ponieważ wyglądem przypominają zapis operacji synchronicznych.

Wykorzystanie async/await do obsługi operacji asynchronicznych wymaga oznaczenia funkcji jako async, które odbywa się poprzez umieszczenie słowa kluczowego async przed deklaracją funkcji lub wyrażeniem funkcyjnym. Następnym krokiem jest poprzedzenie kodu asynchronicznego – znajdującego się w bloku async – słowem kluczowym await. W ten sposób wykonanie funkcji zostanie zastopowane w miejscu wystąpienia await i wznowione w momencie, kiedy operacja asynchroniczna zostanie zakończona. Spójrzmy na poniższy przykład.

async function verifyUser (username, password) {
  const userData = await getUserData(username, password);
  return userData;
}

Funkcja verifyUser została oznaczona jako async. Następnie w jej zakresie wywołano operację asynchroniczną, do czego posłużyła metoda getUserData zwracająca obietnicę. Wywołanie wspomnianej metody zostało poprzedzone słowem kluczowym await, dzięki czemu wykonanie funkcji zostało zastopowane w tym miejscu i wznowione w momencie zakończenia operacji asynchronicznej.
Ważnym jest, aby zapamiętać, że słowo kluczowe await może zostać użyte jedynie wewnątrz bloku oznaczonego jako async, w innym wypadku zwrócony zostanie błąd składni.

Obsługa błędów związanych z użyciem async/await najczęściej odbywa się z wykorzystaniem dobrze znanej składni try catch. Wszystko co należy wykonać to umieścić kod asynchroniczny w bloku try, natomiast zarządzanie ewentualnymi błędami powinno nastąpić wewnątrz bloku catch.

Spójrzmy na poniższy fragment kodu, który jest implementacją naszych wcześniejszych przykładów, tym razem jednak z wykorzystaniem składni async/await.

async function verifyUser (username, passowrd) {
  try {
    const userData = await getUserData(username, passowrd);
    const userRole = await getUserRole(userData);
    const userPermissions = await getUserPermissions(userRole);
    console.log(userPermissions);
  } catch (error) {
    console.error(error);
  }
};

W powyższym przykładzie ponownie używany trzech znanych już metod, które zwracają obietnice. Każdorazowo, wywołanie metody poprzedzamy użyciem słowa kluczowego await, dzięki czemu wykonanie funkcji zostaje zastopowane do momentu, w którym operacja asynchroniczna zakończy swoje działanie. Rezultat każdego wywołania jest zapisywany w zmiennej i przekazywany jako argument do następnej metody. Całość została opakowana w instrukcji try catch, dzięki czemu mamy możliwość obsłużenia ewentualnych błędów.

Powyższy fragment kodu pokazuje jak za sprawą składni async/await możemy wykonywać sekwencję operacji asynchronicznych, która swoim wyglądem przypomina zapis synchroniczny. Takie rozwiązanie gwarantuje czytelność kodu i jednocześnie stanowi kolejny sposób uniknięcia pułapki związanej ze zjawiskiem callback hell.

Podsumowanie

Asynchroniczność niewątpliwie stanowią ważny aspekt programowania w języku JavaScript. Współczesna implementacja tego języka, wzbogacona o kolejne funkcjonalności wynikające z kolejnych wydań specyfikacji ECMAScript udostępnia nam szereg sposobów, pozwalających na zarządzanie operacjami asynchronicznymi. Warto wspomnieć, że przedstawione powyżej sposoby nie wyczerpują dostępnych opcji zarządzania asynchronicznością i warto zgłębić temat czytają np. o generatorach. Bez względu jednak, na to który sposób wybierzemy istotne jest, aby dane rozwiązanie w pełni spełniało wymagania, zapewniało odpowiednią czytelność kodu i dawało możliwość łatwego skalowania w razie ewentualnej potrzeby.


Zdjęcie główne artykułu pochodzi z unsplash.com.

baner

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/wywolanie-zwrotne-obietnice-i-skladnia-async-await-w-javascript/" order_type="social" width="100%" count_of_comments="8" ]