Async/await – podstawy języka JavaScript

Async/await - podstawy języka JavaScript

W poprzednim artykule z cyklu podstaw języka JavaScript, pokazałem jak zastosowanie obietnic pozytywnie wpłynęło na pracę z kodem asynchronicznym i w jaki sposób pozbyć się piekła wywołań zwrotnych (callback hell). Istnieje jednak możliwość zapisu kodu asynchronicznego w sposób jeszcze bardziej czytelny.

Załóżmy że chcemy napisać program, który połączy się z zewnętrznym API w celu pobrania nazwy miasta i na jej podstawie z innego zewnętrznego API wczyta dane pogodowe. W uproszczeniu kod takiego programu mógłby wyglądać następująco:

const cityAPI = 'https://api?q=randomCity';
const weatherAPI = 'https://api?q=weather&cityName=';

const city = fetch(cityAPI);
const weather = fetch(weatherAPI+city.name);
console.log(weather.temp + weather.unit);

Ponieważ jednak pracujemy z kodem asynchronicznym to w takim przypadku użyjemy obietnic i uzyskamy następujący zapis:

const cityAPI = 'https://www.mocky.io/v2/5a945fa435000074009b0e78';
const weatherAPI = 'https://www.mocky.io/v2/5a947dfa35000072009b0eb9?city=';

function asyncGetCity() {
    return fetch(cityAPI).then(response => response.json());
}

function asyncGetWeather(city) {
    return fetch(weatherAPI+city).then(response => response.json());
}

return asyncGetCity()
    .then(function (city) {
        return asyncGetWeather(city.name);
    })
    .then(function(weather) {
        console.log(weather.temp + weather.unit);
    });

https://jsfiddle.net/q7dmoas0/

Mamy tutaj do czynienia z typowym asynchronicznym łańcuchem zależności. Potrzebujemy posiadać nazwę miasta, aby na jego podstawie pobrać informacje pogodowe.

Jak widać użycie obietnic w porównaniu z pierwszym zapisem niesie ze sobą pewną nadmiarowość, ale także i ograniczenia. Jeśli chcielibyśmy wypisać w ostatnim wywołaniu oprócz temperatury również nazwę miasta, musielibyśmy zapisać ją w dodatkowej zmiennej.

let cityName;
return asyncGetCity()
    .then(function(city) {
        cityName = city.name;
        return asyncGetWeather(cityName);
    })
    .then(function(weather) {
        console.log(cityName)
        console.log(weather.temp + weather.unit);
    })

https://jsfiddle.net/ehboqb8f/

Czy nie było by idealnie, gdybyśmy mieli możliwość zapisu kodu asynchronicznego w taki sposób, aby był on tak samo czytelny jak kod synchroniczny z pierwszego przykładu?

Generatory

Jedną z możliwości poprawy czytelności jest zapis z użyciem generatorów, wprowadzonych do języka JavaScript od wersji ES6. W skrócie, generator to specjalny rodzaj funkcji (oznaczamy go za pomocą * umiejscowionej za słowem kluczowym function), której przebieg może być zatrzymywany i wznawiany z zachowaniem kontekstu (variable bindings). Wywołanie generatora nie wykonuje od razu poleceń w nim zawartych, zwraca natomiast obiekt iteratora. Następnie za pomocą metody next() wykonywany jest kod wewnątrz funkcji.

Zobaczmy na przykładzie w jaki sposób działa generator w JavaScript.

function *foo(x) {
    yield x + 1;
    let y = yield 'Hello'; 
    return x + y;
}

var gen = foo(5);
console.log(gen.next()); //{ value: 6, done: false }
console.log(gen.next()); //{ value: 'Hello', done: false }
console.log(gen.next(8)); //{ value: 13, done: true }

https://jsfiddle.net/hu6f72Le/

Przebieg powyższego skryptu jest następujący.

  1. Podczas przypisania funkcji do zmiennej var gen = foo(5) zwracany jest obiekt iteratora.
  2. Pierwsze wywołanie metody next() na obiekcie iteratora powoduje przypisanie wartości 5 do zmiennej x, zatrzymanie wykonywania funkcji na słowie kluczowym yield oraz zwróceniu wartości {value: 6, done: false}.
  3. Drugie wywołanie metody next() powoduje zatrzymanie wykonywania kodu na drugim słowie kluczowym yield i zwrócenie komunikatu 'Hello' w postaci {value: „Hello”, done: false}.
  4. Kolejne wykonanie metody next(), tym razem z przekazaną wartością 8, przypisuje przekazaną wartość w miejscu poprzedniego zatrzymania funkcji, czyli let y = yield 'Hello' (let y = 8), zwraca wynik z obliczeń return x + y (5 + 8) oraz informację o zakończeniu działania iteratora done: true. W konsoli ukazuje się nam wpis: {value: 13, done: true}.

Nie chciałbym jednak skupiać się w tym miejscu na szczegółowym opisie możliwości generatorów, a bardziej na ich połączeniu z obietnicami i zastosowaniu w pracy z kodem asynchronicznym. Tak więc, powracając do przykładu z początku artykułu użyjmy generatora, aby pobrać informacje pogodowe dla danego miasta.

const cityAPI = 'https://www.mocky.io/v2/5a945fa435000074009b0e78';
const weatherAPI = 'https://www.mocky.io/v2/5a947dfa35000072009b0eb9?city=';

function *app(){
    const city = yield fetch(cityAPI).then(r => r.json());
    const weather = yield fetch(weatherAPI+city.name).then(r => r.json());
    console.log(city.name);
    console.log(weather.temp + weather.unit);
}

const generator = app();
start(generator);

function start(generator) {
   runner();
   function runner(resume){
       const next = generator.next(resume);
       const promise = next.value;
       if (next.done) { return; }
       promise.then(result => runner(result))
   }
}

https://jsfiddle.net/qct8s58o/

Funkcja *app() pokazuje siłę generatorów w pracy z kodem asynchronicznym. Porównując kod zawarty w tej funkcji z kodem synchronicznym z początku artykułu, zauważysz, że jest on niemal identyczny. To wielka zaleta stosowania generatorów w połączeniu z obietnicami. Jeśli chodzi o metodę start(), na tę chwilę nie zaprzątajmy sobie nią głowy. Jest ona szczegółem implementacyjnym, i na dobrą sprawę moglibyśmy w tym miejscu użyć bibiliotekę opakowującą np. promise.coroutine lub co.

Async/await

Po tym obszernym wstępie przejdźmy w końcu do omówienia async/await. Z wiedzą jaką posiadamy na temat generatorów, przesiadka na async/await będzie niezwykle prosta. Weźmy funkcję function *app() utworzoną wcześniej i zamieńmy function * na słowo kluczowe async function oraz yield na await.

const cityAPI = 'https://www.mocky.io/v2/5a945fa435000074009b0e78';
const weatherAPI = 'https://www.mocky.io/v2/5a947dfa35000072009b0eb9?city=';

async function app() {
    const city = await fetch(cityAPI).then(r => r.json());
    const weather = await fetch(weatherAPI+city.name).then(r => r.json());
    console.log(city.name);
    console.log(weather.temp + weather.unit);
}
app();

https://jsfiddle.net/a7o19vgg/

Tak, to wszystko. Rezultat działania będzie ten sam co w przypadku generatora, zauważ jednak że nie mamy tutaj potrzeby użycia funkcji pomocniczej start(). Zamiast tego, JavaScript używa wbudowanych mechanizmów do obsługi tego typu wyrażenia.

Funkcja async zawsze zwraca obiekt Promise. Jeśli wewnątrz funkcji wystąpi błąd, obiekt Promise zostanie odrzucony (rejected), jeśli Promise zostanie spełniony (resolve), funkcja async zwróci wartość. Oprócz poprawy czytelności stosowanie async/await daje nam jeszcze kilka dodatkowych korzyści.

Obsługa błędów

Używając async/await mamy możliwość użycia bloku try/catch w celu przechwytywania błędów w kodzie asynchronicznym.

Aby w pełni obsłużyć możliwość przechwycenia błędów synchronicznych i asynchronicznych w kodzie z użyciem obiektów Promise, musimy zastosować następujące rozwiązanie.

const request = () => {
  try {
    getSome()
      .then(result => {
        const data = JSON.parse(result)
        console.log(data)
      }).catch((err) => {
         console.log(err)
      })
  } catch (err) {
    console.log(err)
  }
}

Zobaczmy teraz jak może to wyglądać z użyciem async/await.

const request = async () => {
  try {
    const data = JSON.parse(await getSome())
    console.log(data)
  } catch (err) {
    console.log(err)
  }
}

Obsługa wyrażeń warunkowych

Spójrzmy teraz na nieco bardziej skomplikowaną logikę aplikacji. Załóżmy że na podstawie danych otrzymanych z getSome() musimy podjąć decyzję czy wykonać jeszcze jedno zapytanie do API czy zwrócić otrzymaną wartość. Kod zapisany za pomocą obietnic wyglądałby następująco:

const request = () => {
  return getSome()
    .then(data => {
      if (data.needsMore) {
        return anotherRequest(data)
          .then(moreData => {
            console.log(moreData)
            return moreData
          })
      } else {
        console.log(data)
        return data
      }
    })
}

Znacznie bardziej czytelnie jest gdy użyjemy async/await.

const request = async () => {
  const data = await getJSON()
  if (data.needsMore) {
    const moreData = await anotherRequest(data);
    console.log(moreData)
    return moreData
  } else {
    console.log(data)
    return data    
  }
}

Współbieżność z użyciem async/await

Spójrzmy na następujący przykład, w którym pobierzemy dwa razy nazwę miasta z zewnętrznego API.

const cityAPI = 'https://www.mocky.io/v2/5a945fa435000074009b0e78';

async function app(){
    const city1 = await fetch(cityAPI).then(r => r.json());
    const city2 = await fetch(cityAPI).then(r => r.json());

    console.log(city1.name);
    console.log(city2.name);
}
app();

https://jsfiddle.net/71n9n3tk/

Gdy wykonamy powyższy skrypt w konsoli w zakładce Network możemy zobaczyć że requesty do API wykonywane są w sposób sekwencyjny.

Sekwencyjne pobranie danych z API
Sekwencyjne pobranie danych z API

Zmieńmy zatem nasz kod w taki sposób, aby zapytania do API działały współbieżnie.

const cityAPI = 'https://www.mocky.io/v2/5a945fa435000074009b0e78';

async function app(){
    const city1Request = fetch(cityAPI).then(r => r.json());
    const city2Request = fetch(cityAPI).then(r => r.json());
    
    const city1 = await city1Request;
    const city2 = await city2Request;

    console.log(city1.name);
    console.log(city2.name);
}

app();

https://jsfiddle.net/vb23xvpm/

W pierwszej części skryptu jednocześnie uruchamiamy zapytania do zewnętrznego API za pomocą metody fetch(). Następnie oczekujemy na poprawne rozwiązanie obietnic z kroku pierwszego. Całość czeka tak długo, aż uda się rozwiązać obietnice i zwrócić wynik.

Współbieżne pobranie danych z API
Współbieżne pobranie danych z API

Alternatywnie możemy użyć metody Promise.all() podczas oczekiwania na rozwiązanie większej ilości obietnic. Wynik skryptu będzie taki sam jak poprzednio.

const cityAPI = 'https://www.mocky.io/v2/5a945fa435000074009b0e78';

async function app(){
    const city1Request = fetch(cityAPI).then(r => r.json());
    const city2Request = fetch(cityAPI).then(r => r.json());
    
    const [city1, city2] = await Promise.all([city1Request, city2Request]);;

    console.log(city1.name);
    console.log(city2.name);
}
app();

https://jsfiddle.net/6agnreme/

Podsumowanie

Składnia async/await pojawiła się w wersji ES8 (ECMAScript 2017) języka JavaScript, nareście wprowadzając intuicyjność do pracy z kodem asynchronicznym. Async/await jest wspierane przez wszystkie nowoczesne przeglądarki. Programiści Node mogą korzystać z nowej składni od wersji 8. Jeśli jednak na liście brakuje wsparcia dla środowiska którego używasz możesz skorzystać z jednego z narzędzi takich jak Babel lub biblioteka asyncawait.

Wsparcie dla async function
Wsparcie dla async function
NodeStart - Twórz back-end w JavaScript / TypeScript
Z zawodu i zamiłowania programista. Jara mnie wszystko, co potrafi zmienić nieciekawy input w ciekawy output, czyli m.in. programowanie, gotowanie, muzyka i fotografia.
PODZIEL SIĘ