Przyjrzyjmy się Async/Await w JavaScript na przykładach

Autor artykułu analizuje przykłady Async/Await w JavaScript. Ogólnie rzecz biorąc, Async/Await to wygodny sposób pisania kodu asynchronicznego. Zanim pojawiła się ta funkcja, taki kod był pisany przy użyciu wywołań zwrotnych i obietnic. Autor oryginalnego artykułu odkrywa zalety Async/Await, analizując różne przykłady.

Przypomnienie: dla wszystkich czytelników „Habr” - rabat w wysokości 10 000 rubli przy zapisywaniu się na dowolny kurs Skillbox przy użyciu kodu promocyjnego „Habr”.

Skillbox poleca: Kurs edukacyjny on-line „Programista Java”.

Callback

Callback to funkcja, której wywołanie jest opóźnione na czas nieokreślony. Wcześniej wywołania zwrotne były używane w tych obszarach kodu, w których nie można było natychmiast uzyskać wyniku.

Oto przykład asynchronicznego odczytu pliku w Node.js:

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

Problemy pojawiają się, gdy trzeba wykonać kilka operacji asynchronicznych jednocześnie. Wyobraźmy sobie taki scenariusz: do bazy użytkowników Arfat zostaje wysłane żądanie, należy odczytać jej pole profile_img_url i pobrać obraz z serwera Someserver.com.
Po pobraniu konwertujemy obraz do innego formatu, np. z PNG do JPEG. Jeśli konwersja przebiegła pomyślnie, na adres e-mail użytkownika zostaje wysłany list. Następnie informacja o zdarzeniu zostaje wpisana do pliku transforms.log ze wskazaniem daty.

Warto zwrócić uwagę na nakładanie się wywołań zwrotnych i dużą liczbę }) w końcowej części kodu. Nazywa się to piekłem zwrotnym lub piramidą zagłady.

Wady tej metody są oczywiste:

  • Ten kod jest trudny do odczytania.
  • Trudno jest także poradzić sobie z błędami, co często prowadzi do złej jakości kodu.

Aby rozwiązać ten problem, do JavaScriptu dodano obietnice. Umożliwiają zastąpienie głębokiego zagnieżdżenia wywołań zwrotnych słowem .then.

Pozytywnym aspektem obietnic jest to, że czynią kod znacznie bardziej czytelnym, od góry do dołu, a nie od lewej do prawej. Obietnice mają jednak również swoje problemy:

  • Musisz dodać dużo .then.
  • Zamiast try/catch do obsługi wszystkich błędów używany jest .catch.
  • Praca z wieloma obietnicami w jednej pętli nie zawsze jest wygodna; w niektórych przypadkach komplikuje to kod.

Oto problem, który pokaże znaczenie ostatniego punktu.

Załóżmy, że mamy pętlę for, która wypisuje sekwencję liczb od 0 do 10 w losowych odstępach czasu (0–n sekund). Korzystając z obietnic, musisz zmienić tę pętlę tak, aby liczby były drukowane w kolejności od 0 do 10. Zatem jeśli wydrukowanie zera zajmie 6 sekund, a wydrukowanie jedynki zajmie 2 sekundy, najpierw powinno zostać wydrukowane zero, a następnie rozpocznie się odliczanie do wydrukowania tego.

I oczywiście nie używamy Async/Await ani .sort do rozwiązania tego problemu. Przykładowe rozwiązanie znajduje się na końcu.

Funkcje asynchroniczne

Dodanie funkcji asynchronicznych w ES2017 (ES8) uprościło zadanie pracy z obietnicami. Zauważam, że funkcje asynchroniczne działają „nad” obietnicami. Funkcje te nie reprezentują jakościowo różnych koncepcji. Funkcje asynchroniczne mają stanowić alternatywę dla kodu korzystającego z obietnic.

Async/Await umożliwia organizację pracy z kodem asynchronicznym w stylu synchronicznym.

Zatem znajomość obietnic ułatwia zrozumienie zasad Async/Await.

składnia

Zwykle składa się z dwóch słów kluczowych: async i Wait. Pierwsze słowo zamienia funkcję na asynchroniczną. Takie funkcje pozwalają na użycie Wait. W każdym innym przypadku użycie tej funkcji spowoduje wygenerowanie błędu.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async wstawia się na samym początku deklaracji funkcji, a w przypadku funkcji strzałkowej pomiędzy znakiem „=” a nawiasami.

Funkcje te można umieścić w obiekcie jako metody lub użyć w deklaracji klasy.

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

Uwaga! Warto pamiętać, że konstruktory klas i metody pobierające/ustawiające nie mogą być asynchroniczne.

Semantyka i zasady wykonania

Funkcje asynchroniczne są w zasadzie podobne do standardowych funkcji JS, ale są wyjątki.

Zatem funkcje asynchroniczne zawsze zwracają obietnice:

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

W szczególności fn zwraca ciąg hello. Cóż, ponieważ jest to funkcja asynchroniczna, wartość ciągu jest opakowana w obietnicę za pomocą konstruktora.

Oto alternatywny projekt bez Async:

function fn() {
  return Promise.resolve('hello');
}
 
fn().then(console.log);
// hello

W takim przypadku promesa jest zwracana „ręcznie”. Funkcja asynchroniczna jest zawsze opakowana nową obietnicą.

Jeśli zwracana wartość jest pierwotna, funkcja asynchroniczna zwraca wartość, zawijając ją w obietnicę. Jeśli zwracaną wartością jest obiekt obietnicy, jej rozwiązanie jest zwracane w nowej obietnicy.

const p = Promise.resolve('hello')
p instanceof Promise;
// true
 
Promise.resolve(p) === p;
// true
 

Ale co się stanie, jeśli w funkcji asynchronicznej wystąpi błąd?

async function foo() {
  throw Error('bar');
}
 
foo().catch(console.log);

Jeśli nie zostanie przetworzony, foo() zwróci obietnicę z odrzuceniem. W tej sytuacji zamiast Promise.resolve zostanie zwrócony Promise.reject zawierający błąd.

Funkcje asynchroniczne zawsze generują obietnicę, niezależnie od tego, co zostanie zwrócone.

Funkcje asynchroniczne wstrzymują się przy każdym oczekiwaniu.

Wait wpływa na wyrażenia. Jeśli zatem wyrażenie jest obietnicą, funkcja asynchroniczna zostanie zawieszona do czasu spełnienia obietnicy. Jeśli wyrażenie nie jest obietnicą, jest konwertowane na obietnicę za pomocą Promise.resolve, a następnie uzupełniane.

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

A oto opis działania funkcji fn.

  • Po wywołaniu pierwsza linia jest konwertowana z const a = Wait 9; in const a = czekaj na obietnicę.resolve(9);.
  • Po użyciu Await wykonywanie funkcji zostaje wstrzymane do czasu uzyskania przez a wartości (w obecnej sytuacji jest to 9).
  • opóźnieniaAndGetRandom(1000) wstrzymuje wykonywanie funkcji fn do czasu jej zakończenia (po 1 sekundzie). To skutecznie zatrzymuje funkcję fn na 1 sekundę.
  • opóźnienieAndGetRandom(1000) poprzez rozwiązanie zwraca losową wartość, która jest następnie przypisana do zmiennej b.
  • Otóż ​​przypadek ze zmienną c jest podobny do przypadku ze zmienną a. Potem wszystko zatrzymuje się na sekundę, ale teraz funkcja DelayAndGetRandom(1000) nic nie zwraca, ponieważ nie jest to wymagane.
  • W rezultacie wartości oblicza się za pomocą wzoru a + b * c. Wynik jest owijany obietnicą przy użyciu Promise.resolve i zwracany przez funkcję.

Te przerwy mogą przypominać generatory z ES6, ale coś w tym jest swoje powody.

Rozwiązanie problemu

Cóż, teraz spójrzmy na rozwiązanie problemu wspomnianego powyżej.

Funkcja finishMyTask używa funkcji Await do oczekiwania na wyniki operacji, takich jak queryDatabase, sendEmail, logTaskInFile i inne. Jeśli porównasz to rozwiązanie z tym, w którym zastosowano obietnice, podobieństwa staną się oczywiste. Jednak wersja Async/Await znacznie upraszcza wszystkie zawiłości składniowe. W tym przypadku nie ma dużej liczby wywołań zwrotnych i łańcuchów typu .then/.catch.

Oto rozwiązanie z wyjściem liczb, istnieją dwie opcje.

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

A oto rozwiązanie wykorzystujące funkcje asynchroniczne.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Błąd przetwarzania

Nieobsługiwane błędy są pakowane w odrzuconą obietnicę. Jednak funkcje asynchroniczne mogą używać try/catch do synchronicznej obsługi błędów.

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));
 
// Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }
 
return 'perfect number';
}

canRejectOrReturn() to funkcja asynchroniczna, która albo kończy się sukcesem („idealna liczba”), albo kończy się niepowodzeniem z powodu błędu („Przepraszamy, liczba jest za duża”).

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Ponieważ powyższy przykład oczekuje wykonania canRejectOrReturn, jego własne niepowodzenie spowoduje wykonanie bloku catch. W rezultacie funkcja foo zakończy się wynikiem niezdefiniowanym (gdy w bloku try nie zostanie zwrócone nic) lub wyłapie błąd. W rezultacie ta funkcja nie zawiedzie, ponieważ try/catch obsłuży samą funkcję foo.

Oto kolejny przykład:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Warto zwrócić uwagę na fakt, że w przykładzie z foo zwracana jest funkcja canRejectOrReturn. Foo w tym przypadku albo kończy się liczbą idealną, albo zwraca błąd („Przepraszamy, liczba jest za duża”). Blok catch nigdy nie zostanie wykonany.

Problem polega na tym, że foo zwraca obietnicę przekazaną z canRejectOrReturn. Zatem rozwiązanie foo staje się rozwiązaniem canRejectOrReturn. W tym przypadku kod będzie składał się tylko z dwóch linii:

try {
    const promise = canRejectOrReturn();
    return promise;
}

Oto, co się stanie, jeśli użyjesz funkcji Wait i Return razem:

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

W powyższym kodzie foo zakończy się pomyślnie zarówno z idealną liczbą, jak i wyłapanym błędem. Tutaj nie będzie odmów. Ale foo powróci z canRejectOrReturn, a nie z niezdefiniowanym. Upewnijmy się, usuwając linię return Wait canRejectOrReturn():

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

Typowe błędy i pułapki

W niektórych przypadkach użycie Async/Await może prowadzić do błędów.

Zapomniane oczekiwanie

Zdarza się to dość często - słowo kluczowe Wait zostaje zapomniane przed obietnicą:

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

Jak widać, w kodzie nie ma funkcji Wait ani Return. Dlatego foo zawsze kończy się z niezdefiniowanym bez 1-sekundowego opóźnienia. Ale obietnica zostanie spełniona. Jeśli zgłosi błąd lub odrzucenie, zostanie wywołana funkcja UnhandledPromiseRejectionWarning.

Funkcje asynchroniczne w wywołaniach zwrotnych

Funkcje asynchroniczne są dość często używane w .map lub .filter jako wywołania zwrotne. Przykładem jest funkcja fetchPublicReposCount(username), która zwraca liczbę otwartych repozytoriów na GitHubie. Załóżmy, że jest trzech użytkowników, których danych potrzebujemy. Oto kod tego zadania:

const url = 'https://api.github.com/users';
 
// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

Potrzebujemy kont ArfatSalman, octocat, norvig. W tym przypadku robimy:

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Warto zwrócić uwagę na opcję Await w wywołaniu zwrotnym .map. Tutaj counts to tablica obietnic, a .map to anonimowe wywołanie zwrotne dla każdego określonego użytkownika.

Zbyt konsekwentne użycie Wait

Weźmy ten kod jako przykład:

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

Tutaj numer repo jest umieszczany w zmiennej Count, a następnie liczba ta jest dodawana do tablicy Counts. Problem z kodem polega na tym, że dopóki z serwera nie dotrą dane pierwszego użytkownika, wszyscy kolejni użytkownicy będą w trybie gotowości. Tym samym przetwarzany jest tylko jeden użytkownik na raz.

Jeśli np. przetworzenie jednego użytkownika zajmuje około 300 ms, to dla wszystkich użytkowników jest to już drugi, czas spędzony liniowo zależy od liczby użytkowników. Ponieważ jednak uzyskanie liczby repo nie jest od siebie zależne, procesy można zrównoleglić. Wymaga to pracy z .map i Promise.all:

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all otrzymuje szereg obietnic jako dane wejściowe i zwraca obietnicę. Ta ostatnia, po zakończeniu wszystkich obietnic w tablicy lub przy pierwszym odrzuceniu, zostaje zakończona. Może się zdarzyć, że nie wszystkie wystartują w tym samym czasie – aby zapewnić równoczesny start, można zastosować p-mapę.

wniosek

Funkcje asynchroniczne stają się coraz ważniejsze dla rozwoju. Cóż, do adaptacyjnego wykorzystania funkcji asynchronicznych powinieneś użyć Iteratory asynchroniczne. Programista JavaScript powinien być w tym dobrze zaznajomiony.

Skillbox poleca:

Źródło: www.habr.com

Dodaj komentarz