Asynchroniczność, Promises, Fetch API, async await i XMLHttpRequest w JS

Przyjrzyjmy się prawdopodbnie najważniejszym konstrukcjom języka javascript w pracy frontendowca, które mnie oraz wielu innym frontendowcom spędziły sen z powiek. Porozmawiajmy o obietnicach, fetchach, async await oraz starszego XMLHttpRequest. Wszystko w pigułce.

Promise to relatywnie nowa konstrukcja javascript wprowadzona w EcmaScript 6. Jest to odpowiedź na składnię wykorzystującą obiekty konstruktora XMLHttpRequest, który to powodował bałagan w kodzie i tzw. Callback Hell, o którym powiemy sobie nieco później. Starsi stażem programiści JS nie mieli więc łatwego życia. Oprócz tego obietnice umożlwiają wprawne posługiwanie się asynchronicznymi requestami, a konstrukcja async await wprowadzona nieco później ułatwia jeszcze bardziej podejście do łączenia się z API. Wspomnimy sobie również o Fetch API, które oparte jest na Promisach i skutecznie stawia czoła bibliotece axios. Zapraszam do dalszej części tekstu.

XMLHttpRequest

W zamierzchłych czasach, jeszcze przed ES6 istniał pewien sposób na łączenie się z serwerem i fetchowaniem danych po froncie. Sposób ten nazywa się XMLHttpRequest, który nadal jest wykorzystywany i nie jest trudno na niego trafić w starszych projektach.

Przyjrzyjmy się jego konstrukcji. Do przykładowych snippetów kodu będę posługiwał się publicznym i otwartym api o nazwie Movie Quote, które można znaleźć pod tym linkiem.

const BASE_URL = 'https://movie-quote-api.herokuapp.com';

const req = new XMLHttpRequest();
req.open('GET', BASE_URL, true);  // w trzecim argumencie podajemy, czy zapytanie ma się odbywać asynchronicznie
req.send();

req.onreadystatechange = () => {
    if(req.readyState === 4) {
        if(req.status === 200) {
            console.log(req.status)
        }
    }
}

Jak widzisz, używanie starej składni, sprawia konieczność operowania na metodach prototypu i wykonywaniem callbacków. Request posiada dane stany w zależności od zwracanej odpowiedzi i na tej podstawie podejmowana jest akcja. Wyobraź sobie obsłużenie wszystkich możliwych przypadków - masa zbędnego kodu i komplikacji. Dodajmy sobie do tego dodatkowy request wewnątrz pierwszego i mamy wcześniej wspomniany callback hell i spaghetti code. Zaznaczam, że nie zamiarzam tutaj szczegółowo opisywać tego rozwiązania. Jest raczej ono historią w javascript, którą jednak nadal można spotkać w kodzie. Ważnym jest, żeby wiedzieć, że takie coś się wykorzystywało i w razie potrzeby, przysiąść do dokumentacji. Jeśli kogoś bardziej interesują kwestię związanę z XMLHttpRequest oraz funkcji request(), zapraszam do linków w źródłach. Dziś skupimy się na bardziej nowoczesnych przykładach pracy z web API.

Promises

Przyjrzyjmy się promisom lub - jak wolisz - obietnicom, czyli konstrukcji składniowej javascript wprowadzonej w specyfikacji ES6. Omówimy, z czym się wiąże używanie promisów, jakie problemy rozwiązują i jakie są najprzydatniejsze funkcje związanych z ich prototypem.

Obietnice wykorzystuje się do akcji asynchronicznych, niekoniecznie związanych tylko z web API. Oznacza to, że możemy zdefiniować sobie promise w dowolnym momencie:

const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("Success!");
    }, 2000);
  }).then(resp => console.log(resp)).catch(err => {
      console.log(err);
  });

Konstruktor Promise przyjmuje funkcję z dwoma parametrami: resolve oraz reject. Ten pierwszy oznacza sukces, a drugi wyrzucenie z jakiego tylko chcemy powodu erroru. W momencie sukcesu obietnica może przekazać dany obiekt poprzez konstrukcję łańcuchową then lub przechwycić wyjątek i obsłużyć go za pomocą konstrukcji catch. Oczywiście alternatywą jest blok try catch

Sprawdźmy co siedzi pod zmienną promise:

console.log(promise);
zdjęcie tematyczne
obiekt promise posiada stan pending - może posiadać również stan fullfiled (dla promisów zakończonych sukcesem) lub rejected - zrejectowanych

Podobnie jak XMLHttpRequest, obietnice posiadają swój stan. W momencie deklaracji obiektu promisa, będzie miał on status pending - jest to operacja asynchroniczna (tym bardziej, że wewnątrz powyższego ciała promisa posiadamy setTimout, który zostanie wykonany po 2 sekundach). Jak otrzymać synchroniczną akcję powiemy sobie później. Spróbujmy przekształcić sobie pierwotny kod w postać funkcji, która będzie zwracać promise i jego stan. 

const randomNumber = 1;

const action = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      randomNumber === 0 ? reject('Something went wrong!') : resolve('Success!');
    }, 2000);
  })
}

action().then(response => {
  console.log(response);    // Success!
})

Tym sposobem otrzymaliśmy funkcję, którą możemy posługiwać się kiedy tylko chcemy i korzystać z dobrobytów obietnic. Funkcje resolve i reject przyjmują, to co promise ma zwrócić odpowiednio dla sukcesu i błędu. W powyższym wypadku jest to string Success!, a więc mamy możliwość dowolnej manipulacji, tym co chcemy zwrócić.

Async await

Co jednak jeśli nie chcemy wykonywać operacji w blokach then oraz catch, tylko posługiwać się otrzymanymi danymi z asynchronicznej operacji w tym samym zakresie leksykalnym, co wykonywana funkcja zwracająca promise? Z pomocą przychodzi nam kontrukcja async await wprowadzona w ES2017, która rozwiązuje problem właśnie w takich przypadkach. Daje ona możliwość zrobienia z funkcji asynchronicznej (która jest domyślnym typem funkcji w js) ... funkcję synchroniczną. Co nam to daje? Po pierwsze, rozwiązuję powyższy problem leksykalny i zapobiega zjawiskom typu callback hell. Ponadto, zapobiega częstym błędom wynikającym z użycia wielu requestów w kodzie. Sczególnie we frameworkach. Przykładowo: komponent renderuje się szybciej od wykonania requesta, a wykorzystuje zasób, który request chce pobrać. Na skutek tego runtime lub skrypt się wywala. Rozwiązaniem jest właśnie span class="tag">async await, dzięki któremu synchroniczna funkcja wykona się przed wyrenderowaniem komponentu - mamy wtedy pewność, że zapytanie zostanie "zresolvowane", a my otrzymamy oczekiwane dane lub obsłużymy błąd. Dobrze jest wtedy zapewnić jakiś loader, by użytkownicy wiedzieli, co jest grane i byli spokojni o to, że zaraz dostaną coś na ekranie. W przeciwnym wypadku mogą się zniecierpliwić. Spójrzmy na rozwinięcie kodu:

const randomNumber = 1;

const action = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      randomNumber === 0
        ? reject({
            status: {
              status: 'not ok',
              message: 'error!',
            },
          })
        : resolve({
            status: 'ok',
            message: 'success!',
          });
    }, 2000);
  });
};

(async () => {
  const response = await action().then((response) => response);

  console.log(response);
})();

Wykorzystałem tutaj samowykonującą się funkcję (IIFE). Można to zrobić również na zwykłej funkcji. Jak widzisz, dzięki zastosowaniu async await możemy zachować ten sam zakres danych, w którym wykonujemy asynchroniczną akcję lub request. Spójrz również na zwracane wartości w promisie. Są tam teraz obiekty, które zwracają 2 klucze: wartość oraz status. Poniekąd emitują one zachowanie jakiegoś requesta, ale jest do dummy value. Zauważ, że sukces promisa uzależniony jest od zmiennej randomNumber. Jeżeli wynosi ona 1, to promise wykona funkcję resolve(), co poskutkuję wykonaniem się bloku then.

Czegoś tu jednak brakuje. Można się domyslić, że w żaden sposób nie obsługujemy tutaj błędu w promisie, który wystąpi, gdy zmienimy wartość zmiennej na 0. Jeśli nie przechwycimy errora, to parser wyrzuci nam error w konsoli, czego nie chcemy. Pamiętaj, żeby zawsze obsłużyć wyjątki - zwykły użytkownik (prawie) nigdy nie wejdzie w konsolę! Załatwmy to zatem w następujący sposób:

(async () => {
  try {
    const response = await action()
      .then((response) => response)
      .catch((error) => {
        console.log(error);
        throw {
          statusCode: 522,
          message: error.message,
        };
      });
  } catch (error) {
    console.log(error.message);
  }
})();

Dzięki throw rzucamy tutaj obiekt z danymi, z których można skorzystać w obsłudze wyjątku. Przechwytujemy go za pomocą try catch i mamy gotowe rozwiązanie. Jak widzisz, nie wiele wymagało, aby napisać prostą abstrackję z wykorzystaniem promisów. Przejdźmy więc sobie do ostaniej rzeczy, o której chcę dziś powiedzieć. Funkcji fetch() z Web API, która jest często zamiennie utożsamiana z samymi promisami. A cała magia w tym, że ona tylko działa na promisach. I jest w tym niesamowicie skuteczna.

Fetch API

Fetch API, a więc funkcja fetch() jest częścią Web platform API zdefiniowaną przez WHATWG i W3C. Nie pochodzi więc z EcmaScript. Działa na dwóch dodatkowych konstruktorach Request() oraz Response(). Fetch() zwraca Promise, który resolvuje tego typy obiekty. W połączeniu tworzą niesamowite narzędzie do tworzenia requestów, ułatwiając przy tym pisanie kodu. Stworzenie prostego zapytania GET za pomocą fetcha jest dziecinne proste:

const BASE_URL = 'https://movie-quote-api.herokuapp.com';

fetch(BASE_URL)
  .then((response) => console.log(response))
  .catch((error) => {
    console.log(error);
  });
zdjęcie tematyczne
fetch() zapewnia odpowiedź obiektu konstruktora Response, co daje pełen zestaw wartości ułatwiających obsługę requestów

Domyślne zapytanie dla funkcji fetch() to GET. Nic nie stoi na przeszkodzie, by zdefiniować inną metodę, body payload, a nawet headery.

const BASE_URL = 'https://movie-quote-api.herokuapp.com';

fetch(BASE_URL, {
  method: 'POST',
  mode: 'cors',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    id: 12345,
  }),
})
  .then((response) => response.json())
  .then((data) => setData(data))
  .catch((error) => {
    //handle error
    console.log(error);
  });

Obiekt Response zapewnia nawet funkcję json(), która konwertuje stringa na obiekt JSON normalnie widoczne dla JSa. Przed wysłaniem danych na serwer, musimy je przekształcić za pomocą JSON.stringify(). Jeszcze lepszym narzędziem do obsługi requestów jest zewnętrzna bilbioteka axios, która jeszcze bardziej ułatwia i przyśpiesza pisanie kodu, co potwierdza tygodniowa liczba pobrań bilbioteki dochądząca czasem nawet do 20 milionów. Do poznania axiosa odsyłam do zewnętrznych żródeł :)

Podsumowanie

Jak wspomniałem na początku: requesty, API, fetchowanie, wymiana danych z backendem są to w mojej opinii najważniejsze elementy pracy we frontendzie. Od doświadczonego frontendowca oczekuje się, że będzie to miał opanowane w jednym paluszku. Dodajmy do tego autoryzację i obsługę wyjątków - te aspekty dobrze zrealizowane w projekcie świadcza o naprawdę dobrze wykonanej aplikacji frontowej. Nie bój się ich i nie uciekaj od nich. To wszystko da się nauczyć. Sam mam świadomość ile jeszcze nie wiem we froncie.

To co tu przedstawiłem to tylko kropla w morzu, wierzchołek góry lodowej, totalna podstawa jeżeli chodzi o pracę z API i backendem. Jak zawsze zachęcam do samodzielnego studiowania i researchu. Mam nadzieję, że dzięki temu artykułowi dowiedziałeś się czegoś nowego o asynchroniczności w javascripcie lub przyswoiłeś/aś sobie coś, co może sprawiło ci kiedyś problem. Bo często kwestie zapytań do API czy backendu stanowią problem dla początkujących. Tak też było w moim wypadku. Do zobaczenia!

Źródła

© Damian Kalka 2021
Wszelkie prawa zastrzeżone