Składnia async/await jest (na tę chwilę) częścią specyfikacji ECMAScript 2017. Ostatnio, w moim prywatnym projekcie, nad którym w pocie czoła pracuję (niebawem napiszę o nim na blogu), całkiem sporo tej składni używam. Postanowiłem więc napisać na jej temat parę słów ponieważ, wydaje mi się, że nie jest ona jeszcze powszechnie stosowana. Zatem zapraszam do lektury!

Jak to się robiło dotychczas

Do tej pory, do obsługi wywołań asynchronicznych w JavaScript wykorzystywaliśmy callbacki oraz obiekty “promise”. Co do callbacków w tym kontekście to pozwólcie, że spuścimy tutaj zasłonę milczenia (o “callback hell” pisałem już zresztą na blogu)… Skupmy się zatem na “promisach” i wyobraźmy sobie taki przykładowy kod (w przykładach będę stosować składnię “ES6+”):

export function asyncAction() {
  return new Promise((resolve, reject) => {
    const successTimeout = Math.random() * 10000;
    const errorTimeout = Math.random() * 10000;

    setTimeout(() => {
      resolve('success');
    }, successTimeout);

    setTimeout(() => {
      reject('error');
    }, errorTimeout);
  });
}

Powyższy przykład jest raczej prosty: losujemy dwa timeouty, które przekazujemy jako parametry funkcji setTimeout. Dzięki temu operacja asynchroniczna zakończy się losowo: sukcesem lub porażką.

Powyższa implementacja nie jest tutaj aż tak istotna. Bardziej interesujący jest przykład użycia funkcji asyncAction:

import { asyncAction } from './example';

function doWork() {
  asyncAction()
    .then(data => {
      console.log(data);
    })
    .catch(error => {
      console.log(error);
    });
}

doWork();

Napewno dobrze już to znasz. Obiekt Promise zwracany przez funkcję asyncAction posiada metodę then pozwalającą na obsługę operacji asynchronicznej zakończonej powodzeniem (w momencie wywołania resolve() w funkcji asyncAction. Posiada on też metodę catch(), która pozwala obsłużyć niepowodzenie operacji asynchronicznej. W przykładzie, w obu przypadkach w konsoli wyświetlany jest odpowiedni komunikat.

Ok, to tyle jeśli chodzi o punkt wyjścia do pokazania jak działa składnia async/await. Na ten temat więcej tym poniżej.

Składnia async/await

Generalnie, składnia async/await to tylko inny sposób zapisu. “Pod spodem” nadal wykorzystywane są “promisy”. Pozwala ona jednak na pisanie kodu w sposób bardziej synchroniczny.

Aby wyjaśnić o co chodzi myślę, że najlepiej będzie pokazać przykład. Spójrzmy na przepisany od nowa kod funkcji doWork.

async function doWork() {
  try {
    const data = await asyncAction();
    console.log(`message = ${data}`);
  } catch(error) {
    console.log(`message = ${error}`);
  }
}

doWork();

Objaśnienie przykładu

Pierwsze co się rzuca w oczy to słowo async przed definicją funkcji doWork(). Słowo to powoduje, że funkcja doWork jest od teraz asynchroniczna i co do zasady zwraca ona obiekt Promise (dzieje się to “pod spodem”). Kiedy następuje wywołanie resolve() w takim przypadku? W momencie użycia słowa kluczowego return! Czyli wywołanie return 'success' jest tożsame z wywołaniem resolve('success').

Jak możesz zauważyć, implementacja funkcji doWork dość mocno się zmieniła. Mamy tutaj teraz blok try ... catch ale o tym za chwilę. Najpierw spójrz na linię trzecią. Do zmiennej data przypisujemy wynik wywołania funkcji asyncAction ale jest on jeszcze poprzedzony słowem kluczowym await. Oznacza ono tyle, że wynik działania funkcji asyncAction jest asynchroniczny.

Najciekawsze znajdziesz w kolejnej linii. Niby zwykłe wywołanie console.log(). Jednak zauważ, że za jej pomocą wyświetlana jest wartość zmiennej data, która przecież może jeszcze nie istnieć w tym momencie! I to jest właśnie cała “magia”: jeżeli przy wywołaniu funkcji asynchronicznej (czyli, jak już powiedzieliśmy takiej, która zwraca Promise) użyjemy słowa await to kod, który zależy od wyniku działania tej funkcji również staje się asynchroniczny. Można powiedzieć, że “zaczeka” on na ten wynik i zostanie wykonany dopiero w momencie zakończenia operacji asynchronicznej.

Kolejna sprawa to blok try ... catch. Otóż przy wywołaniu funkcji asynchronicznej poprzedzonej słowem await dostajemy wynik “resolwowania” obiektu Promise. Jak więc obsłużyć przypadek wywołania metody reject? Ano właśnie owijając wywołanie z await blokiem try ... catch. Jak możesz wywnioskować z powyższego przykładu, zmienna przekazana podczas wywołania metody reject jest później dostępna jako parametr dyrektywy catch. Łatwo więc się do niego dostać.

P.S. O ile w naszym przypadku zwracanie obiektu Promise z metody doWork nie jest potrzebne (nie wykorzystujemy wyniku wywołania tej funkcji) o tyle słowo async przed definicją tej funkcji jest wymagane jeśli chce się w niej skorzystać ze słowa kluczowego await!

Czy da się tego używać wszędzie?

Niestety nie bardzo… Składnia async/await zbudowana jest w oparciu o obiekty Promise. Nie da się jej używać ze zwykłymi funkcjami wywołania zwrotnego.

Spójrz na przykład na naszą funkcję asyncAction. Funkcje resolve oraz reject są tam wywoływane wewnątrz callbacków przekazywanych do funkcji setTimeout. Jest to problem ponieważ, jak już wspomniałem, przy składni async/await “resolwowanie” następuje w momencie zwrócenia wartości z funkcji. Czegoś takiego przecież nie zrobimy:

export async function asyncAction() {
  const successTimeout = Math.random() * 10000;
  const errorTimeout = Math.random() * 10000;

  return await setTimeout(() => {
    return 'success';
  }, successTimeout);

  // ...
}

… ponieważ to co jest zwracane w funkcji callback jest “połykane” przez funkcję setTimeout. Co innego gdyby zwracała ona obiekt Promise z tym wynikiem… ale nie zwraca (zamiast tego dostajemy ID timera).

Podsumowanie

Składnia async/await to, jak widzisz, nie jest żadna magia. Jest to po prostu inny sposób pracy z obiektami Promise, dzięki któremu tworzony przez nas kod wygląda bardziej “synchronicznie”. Niewątpliwą zaletą tego podejścia jest też sposób obsługi błędów, który jest spójny z tym w jaki sposób wyłapuje się błędy dla operacji synchronicznych. Często pozwala też nieco “spłaszczyć” łańcuch wywołań operacji asynchronicznych - na przykład jeśli wynik jednej operacji asynchronicznej chcemy przekazać do kolejnej to zamiast zagnieżdżać metody then, wystarczy “poczekać” na wynik pierwszej operacji za pomocą await i przekazać go do kolejnej operacji asynchronicznej (na przykład w następnej linii).

W związku z powyższym, wydaje mi się, że warto to podejście wypróbować ponieważ, kod z jego użyciem wygląda “czyściej” i bardziej logicznie.

P.S. Kody pokazane powyżej dostępne są do przetestowania na githubie.