Async/Await - przydatne techniki i wzorce

Damian Wróblewski - czerwiec 2022

Async/Await - przydatne techniki i wzorce

Spis treści

  1. Pojedyncze zapytania
  2. Wielokrotne zapytania
  3. Obsługa błędów

Asynchroniczność w javascript to temat, który może sprawiać problemy nawet bardziej doświadczonym developerom, dlatego też postanowiłem zebrać w tym poście praktyczne zasady i techniki, które pomagają mi na codzień pracować z asynchronicznymi zapytaniami do API.

W 2017 roku, w 8. edycji standardu ECMAScript wprowadzono async/await - opartą o generatory i obietnice konstrukcję, która pozwala na pracę z kodem asynchronicznym w sposób synchroniczny. Bez wątpienia jest to bardziej wygodna i intuicyjna metoda w porównaniu do Promise. Jest jednak kilka zagadnień które musimy poznać, by skutecznie i świadomie wykorzystywać tę konstrukcję.

Pojedyncze zapytania

W przypadku pojedynczych żądań sprawa wydaje się dość prosta. Wewnątrz funkcji asynchronicznej, przed funkcją zwracającą Promise (np. fetch) stawiamy słowo await, które sprawia, że nasz kod poczeka na wykonanie się kodu asynchronicznego i w efekcie fetch zwróci nam wynik zamiast Promisy:

1async function getUser() {
2 const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
3 console.log(await res.json());
4}
5
6getUser();

Wielokrotne zapytania

Często jednak do czynienia będziemy mieli z sytuacją, kiedy będziemy musieli obsłużyć większą liczbę żądań, z których część będziemy chcieli wysłać równolegle, by zadbać o wydajność, a inne będą musiały być wysłane sekwencyjnie, bo np. będą wykorzystywały dane z poprzednich żądań. W takich przypadkach warto znać kilka zasad i technik, by kod działał zgodnie z naszymi oczekiwaniami.

Strzeż się forEach

Rozważmy prosty przykład. Mamy tablicę identyfikatorów i na jej podstawie chcemy pobrać informacje o użytkownikach. Wiele osób może pokusić się tutaj o skorzystanie z metody tablicowej forEach:

1const ids = [1, 3, 4, 24];
2
3const getUserById = (id) => fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
4
5await ids.forEach(async (id) => {
6 await getUserById(id);
7});

Jaki jest tutaj problem? Otóż wydawać by się mogło, że jeśli postawiliśmy przed getUserById() słowo kluczowe await to nasz kod za każdym razem poczeka na wykonanie się zapytania. Nic bardziej mylnego. Metoda forEach nie jest metodą promise-aware, więc nie zatrzymuje funkcji i nie czeka na await wewnątrz kontekstu tylko uruchamia wszystkie promisy jednocześnie, więc działa inaczej niż podpowiada nam intuicja.

Sekwencyjnie

Dlatego jeśli chcemy uruchomić promisy jedna po drugiej, powinniśmy skorzystać z innych technik:

For loop

Chyba najprostszym i również najczęściej stosowanym przeze mnie sposobem jest wykorzystanie pętli for:

1const ids = [1, 3, 4, 24];
2
3const idLoop = async () => {
4 for (const id of ids) {
5 const user = await getUserById(id);
6 console.log(await user.json());
7 }
8};
9
10idLoop();

W tym przypadku, funkcja wewnątrz się zatrzyma i poczeka na rozwiązanie promisy, co możemy sprawdzić wchodząc w zakładkę network w narzędziach deweloperskich przeglądarki:

sequentially

Jak widzisz zapytania zostały wysłane po kolei.

Reduce

Podobnie sytuacja wygląda jeśli wykorzystamy metodę reduce():

1const ids = [1, 3, 4, 24];
2
3async function asyncReduce(ids) {
4 let result = await ids.reduce(async (previousPromise, id) => {
5 let usersArray = await previousPromise;
6 const userCall = await getUserById(id);
7 const user = await userCall.json();
8 usersArray.push(user);
9
10 return usersArray;
11
12 }, Promise.resolve([]));
13 console.log(result);
14}
15
16asyncReduce(ids);

Warto tutaj zwrócić uwagę, że musimy przekazać rozwiązanie promisy jako początkową wartość.

Async generator

Możemy stworzyć również asynchroniczny generator:

1const ids = [1, 3, 4, 24];
2
3async function* getUsers(ids) {
4 for (const id of ids) {
5 const res = await getUserById(id);
6 yield res.json();
7 }
8};

A następnie uruchomić go od początku do końca za pomocą pętli for:

1(async () => {
2 let users = getUsers(ids);
3 for await (let user of users) {
4 console.log(user);
5 }
6})();

Bądź też uruchamiać każdy krok iteracji niezależnie, mając pełną kontrolę nad tym, kiedy kolejne żądania zostaną wysłane:

1(async () => {
2 const users = getUsers(ids);
3 const user1 = await users.next();
4 const user2 = await users.next();
5 const user3 = await users.next();
6 console.log(user1, user2, user3);
7})();

Równolegle

W przypadku kiedy nie musimy czekać na wynik zapytania w celu wysłania kolejnego, warto wysłać requesty współbieżnie, by zaoszczędzić cenny czas. I w tym przypadku również mamy kilka możliwości:

For await

Ponownie skorzystać możemy z pętli for:

1const ids = [1, 3, 4, 24];
2
3const users = ids.map(id => getUserById(id));
4
5const getUsers = async () => {
6 for await (const user of users) {
7 console.log(await user.json());
8 }
9};
10
11getUsers();

Różnica tutaj polega jednak na tym, że pętlę wykonujemy na tablicy promis users, a słowo await stawiamy zaraz za for.

Tym razem zapytania zostaną wysłane równocześnie:

concurrently

Promise.all

W przypadku Promise.all() musimy pamiętać, że metoda przyjmuje tablicę promis i jeśli któreś z zapytań zakończy się niepowodzeniem, to cała metoda zwróci nam błąd. Dlatego też ta metoda nie powinna być używana w przypadku niestabilnych API.

1const ids = [1, 3, 4, 24];
2
3async function getUsers(ids) {
4 await Promise.all(ids.map(async (id) => {
5 const res = await getUserById(id);
6 console.log(await res.json());
7 }));
8}
9
10getUsers(ids);

Przypisanie obietnic do zmiennej

Spójrzmy teraz na poniższy przykład i zastanówmy się w jaki sposób zostaną wysłane zapytania - w sekwencji czy współbieżnie?

1async function getUsers() {
2 let promise1 = getUserById(1);
3 let promise2 = getUserById(2);
4 let promise3 = getUserById(3);
5
6 let user1 = await promise1;
7 let user2 = await promise2;
8 let user3 = await promise3;
9
10 let finalResult = [user1, user2, user3];
11
12 console.log(finalResult);
13}
14
15getUsers();

Słowo await przed każdą promisą sugeruje, że kod zatrzyma się i poczeka na rozwiązanie każdej obietnicy. Jak już możesz się domyślać, stanie się inaczej :) Przypisanie wywołania funkcji getUserById() do zmiennej sprawi, że kod wykona się synchronicznie i zapytania zostaną już wysłane, niezależnie od tego, co później z tym faktem zrobimy.


Jak widzisz sposobów na wykorzystanie konstrukcji async/await jest sporo i często ich działanie nie jest intuicyjne. To, którą metodę wybierzemy najczęściej będzie podyktowane wymaganiami konkretnego przypadku, zrozumieniem danej konstrukcji czy też preferencjami zespołu.

Obsługa błędów

Na koniec pomówimy jeszcze o obsłudze błędów i ciekawemu rozwiązaniu zapewniającemu lepszą czytelność kodu.

Jak już pewnie wiesz, aby obsłużyć błąd w funkcji asynchronicznej, wystarczy opakować ją w konstrukcję try/catch:

1async function foo() {
2 try {
3 const image = await promiseFunction();
4 } catch(err) {
5 console.log(err);
6 }
7}

Tower of terror

Jeśli jednak mamy do obsłużenia kilka zapytań i chcemy zapewnić obsługę błędów w każdym z tych zapytań niezależnie, kod staje się mniej czytelny:

1async function getUsers() {
2 let user1;
3 let user2;
4 let user3;
5
6 try {
7 user1 = await getUserById(1);
8 } catch(err) {
9 console.log(err);
10 }
11
12 try {
13 user2 = await getUserById(2);
14 } catch(err) {
15 console.log(err);
16 }
17
18 try {
19 user3 = await getUserById(3);
20 } catch(err) {
21 console.log(err);
22 }
23
24 return [user1, user2, user3];
25}

Możemy poprawić czytelność kodu np. poprzez skorzystanie z metody Promise.prototype.catch(), do której przekazujemy callback obsługujący błąd:

1async function getUsers() {
2 let user1;
3 let user2;
4 let user3;
5
6 user1 = await getUserById(1).catch((err) => console.log(err));
7 user2 = await getUserById(2).catch((err) => console.log(err));
8 user3 = await getUserById(3).catch((err) => console.log(err));
9
10 return [user1, user2, user3];
11}

Jeszcze lepszym rozwiązaniem będzie wydzielenie logiki i utworzenie generycznej funkcji obsługującej błędy:

1async function fetchUser(id) {
2 try {
3 const data = await getUserById(id);
4 return [data, null];
5 } catch (err) {
6 console.log(err);
7 return [null, err];
8 }
9}
10
11async function getUsers() {
12 const [data, err] = await fetchUser(1);
13
14 if (err) {
15 console.log(err);
16 }
17 const [data2, err] = await fetchUser(2);
18 const [data3, err] = await fetchUser(3);
19}

Z funkcji fetchUser() zwracamy tablicę dwóch elementów, w której w zależności od wyniku znajdują się dane z API lub błąd oraz null. Dzięki temu możemy później przypisać dane i błąd za pomocą destrukturyzacji (zachowując kolejność) i sprawdzić czy błąd został przekazany.


Asynchroniczność w javaScript to zagadnienie bardzo obszerne, a niektóre mechanizmy działąją wręcz kontrintuicyjnie, dlatego mam nadzieję, że taki zwięzły zestaw technik pomoże Ci uniknąć potencjalnych błędów, a omówione sposoby przydadzą się w codziennej pracy.

Jeżeli znalazłeś jakieś niedopatrzenia lub znasz inne techniki, którymi chciałbyś się podzielić, będę wdzięczny jeśli zostawisz komentarz poniżej 😉


Dołącz do dyskusji